From 595d04dad92bbd2458b4f851afef854efc439ef4 Mon Sep 17 00:00:00 2001 From: Bezrabotnyi Date: Sun, 16 Aug 2026 06:13:36 +0300 Subject: [PATCH 01/36] feat(providers): add local ZCode ACP backend (#10184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(providers): add local ZCode ACP backend * test(snapshots): regenerate translate-path golden for zcode provider The new local ZCode ACP backend (zcode://app-server/stdio) was added to the provider catalog but the translate-path golden snapshot was not regenerated, so the combined suite (provider-translate-path-golden.test.ts) failed on the merged tip. Regenerate the snapshot to include the zcode translate-path entry. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * docs(env): document ZCODE_* vars for the local zcode provider Registers the 11 ZCODE_* env vars read by the zcode executor (.env.example + docs/reference/ENVIRONMENT.md) so the env-doc-sync gate stays green. Co-authored-by: Diego Souza <8016841+diegosouzapw@users.noreply.github.com> * test(autoCombo): include zcode in the glm-family provider set #10184's local zcode backend advertises the full GLM_SHARED_MODELS line-up (registry/zcode, authType none) — same documented case as auggie and devin-cli-agentic. Update auto/glm provider-set assertion to include it. Co-authored-by: Diego Souza <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: roomhacker Co-authored-by: adevwithpurpose Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .env.example | 15 + docs/reference/ENVIRONMENT.md | 11 + open-sse/config/providers/index.ts | 2 + .../config/providers/registry/zcode/index.ts | 18 + open-sse/executors/index.ts | 3 + open-sse/executors/zcode.ts | 375 +++++++++++++++ open-sse/executors/zcodeProtocol.ts | 438 ++++++++++++++++++ src/lib/acp/registry.ts | 9 + src/shared/constants/cliTools.ts | 19 + src/shared/constants/providers.ts | 2 + src/shared/constants/providers/noauth.ts | 18 + src/shared/services/cliRuntime.ts | 11 + tests/fixtures/fake-zcode-app-server.mjs | 167 +++++++ tests/snapshots/provider/translate-path.json | 23 + .../autoCombo/provider-family-combos.test.ts | 5 +- tests/unit/zcode-executor.test.ts | 84 ++++ tests/unit/zcode-protocol.test.ts | 30 ++ tests/unit/zcode-provider.test.ts | 14 + 18 files changed, 1243 insertions(+), 1 deletion(-) create mode 100644 open-sse/config/providers/registry/zcode/index.ts create mode 100644 open-sse/executors/zcode.ts create mode 100644 open-sse/executors/zcodeProtocol.ts create mode 100644 tests/fixtures/fake-zcode-app-server.mjs create mode 100644 tests/unit/zcode-executor.test.ts create mode 100644 tests/unit/zcode-protocol.test.ts create mode 100644 tests/unit/zcode-provider.test.ts diff --git a/.env.example b/.env.example index 80f60b55b8..0881c1b8e4 100644 --- a/.env.example +++ b/.env.example @@ -735,6 +735,21 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # CLI_AUGGIE_BIN=auggie # AUGGIE_BIN=auggie +# ── ZCode (Z.ai GLM coding-plan CLI) local provider ── +# The local "zcode" provider talks to the authenticated ZCode app-server over a +# custom framed stdio protocol. Overrides below tune that stdio lifecycle. +# ZCODE_BIN=zcode +# ZCODE_ARGS=["--some-flag"] +# ZCODE_CWD= +# ZCODE_PROVIDER_ID=builtin:zai-coding-plan +# ZCODE_SERVER_RUNTIME_ROOT=~/.zcode/server +# ZCODE_SERVER_NODE=~/.zcode/server/node +# ZCODE_SERVER_ENTRY=~/.zcode/server/zcode-server.cjs +# ZCODE_STARTUP_TIMEOUT_MS=10000 +# ZCODE_RPC_TIMEOUT_MS=30000 +# ZCODE_TURN_TIMEOUT_MS=120000 +# ZCODE_POLL_INTERVAL_MS=250 + # Override the Hermes Agent home directory (where OmniRoute reads/writes the # Hermes CLI config). Matches the env var the Hermes PowerShell installer sets # on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 86bd169eb8..8fc5a9666a 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -400,6 +400,17 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, | `DEVIN_BRIDGE_SUBAGENT_MODEL` | `DEVIN_BRIDGE_MODEL` | `docker/devin-bridge/compose.yml` | Isolated bridge alias used for Claude Code subagents. | | `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. | | `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). | +| `ZCODE_BIN` | `zcode` | `open-sse/executors/zcode.ts` | Binary used for the local `zcode` provider's stdio client. Falls back to `zcode` on PATH. | +| `ZCODE_ARGS` | — | `open-sse/executors/zcode.ts` | JSON array (≤16 strings) of extra arguments passed to the `zcode` binary when launched via `cliTools`. | +| `ZCODE_CWD` | `process.cwd()` | `open-sse/executors/zcode.ts` | Working directory for the ZCode app-server subprocess. | +| `ZCODE_PROVIDER_ID` | `builtin:zai-coding-plan` | `open-sse/executors/zcode.ts` | Override for the provider id sent to the app-server. | +| `ZCODE_SERVER_RUNTIME_ROOT` | `~/.zcode/server` | `open-sse/executors/zcode.ts` | Root of the ZCode app-server runtime (where the bundled `node` and `zcode-server.cjs` live). | +| `ZCODE_SERVER_NODE` | `/node` | `open-sse/executors/zcode.ts` | Node executable used to host the ZCode app-server. | +| `ZCODE_SERVER_ENTRY` | `/zcode-server.cjs` | `open-sse/executors/zcode.ts` | App-server entry script used to host the ZCode server. | +| `ZCODE_STARTUP_TIMEOUT_MS` | `10000` | `open-sse/executors/zcode.ts` | Startup timeout (ms) before a ZCode app-server launch is considered failed. | +| `ZCODE_RPC_TIMEOUT_MS` | `30000` | `open-sse/executors/zcode.ts` | Per-request RPC timeout (ms) for a ZCode app-server call. | +| `ZCODE_TURN_TIMEOUT_MS` | `120000` | `open-sse/executors/zcode.ts` | Maximum duration (ms) of one ZCode turn before the supervisor times it out. | +| `ZCODE_POLL_INTERVAL_MS` | `250` | `open-sse/executors/zcode.ts` | Polling interval (ms) for ZCode turn completion. | | `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). | ### CLI Profile Auto-Sync diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 6206719384..ff09ad02b6 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -154,6 +154,7 @@ import { command_codeProvider } from "./registry/command-code/index.ts"; import { novitaProvider } from "./registry/novita/index.ts"; import { regoloProvider } from "./registry/regolo/index.ts"; import { devin_desktopProvider } from "./registry/devin-desktop/index.ts"; +import { zcodeProvider } from "./registry/zcode/index.ts"; import { zed_hostedProvider } from "./registry/zed-hosted/index.ts"; import { nanogptProvider } from "./registry/nanogpt/index.ts"; import { scalewayProvider } from "./registry/scaleway/index.ts"; @@ -412,6 +413,7 @@ export const REGISTRY: Record = { novita: novitaProvider, regolo: regoloProvider, "devin-desktop": devin_desktopProvider, + zcode: zcodeProvider, "zed-hosted": zed_hostedProvider, nanogpt: nanogptProvider, scaleway: scalewayProvider, diff --git a/open-sse/config/providers/registry/zcode/index.ts b/open-sse/config/providers/registry/zcode/index.ts new file mode 100644 index 0000000000..cd2a4eece6 --- /dev/null +++ b/open-sse/config/providers/registry/zcode/index.ts @@ -0,0 +1,18 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { GLM_SHARED_MODELS } from "../../../glmProvider.ts"; + +/** + * Local ZCode app-server backend. Authentication remains in the user's local + * ZCode profile (`builtin:zai-coding-plan`); OmniRoute does not receive or + * persist the Z.ai credential. + */ +export const zcodeProvider: RegistryEntry = { + id: "zcode", + alias: "zc", + format: "openai", + executor: "zcode", + baseUrl: "zcode://app-server/stdio", + authType: "none", + authHeader: "none", + models: [...GLM_SHARED_MODELS], +}; diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 3c230ee91f..87f2c93e8d 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -33,6 +33,7 @@ import { NlpCloudExecutor } from "./nlpcloud.ts"; import { DevinDesktopExecutor } from "./devin-desktop.ts"; import { ZedHostedExecutor } from "./zed-hosted.ts"; import { DevinCliExecutor } from "./devin-cli.ts"; +import { ZcodeExecutor } from "./zcode.ts"; import { DevinCliAgenticExecutor } from "./devin-cli-agentic.ts"; import { AuggieExecutor } from "./auggie.ts"; import { DeepSeekWebExecutor } from "./deepseek-web.ts"; @@ -134,6 +135,8 @@ const executors = { "devin-desktop": new DevinDesktopExecutor(), "zed-hosted": new ZedHostedExecutor(), "devin-cli": new DevinCliExecutor(), + zcode: new ZcodeExecutor(), + zc: new ZcodeExecutor(), // Alias "devin-cli-agentic": new DevinCliAgenticExecutor(), devin: new DevinCliExecutor(), // Alias "deepseek-web": new DeepSeekWebWithAutoRefreshExecutor(), diff --git a/open-sse/executors/zcode.ts b/open-sse/executors/zcode.ts new file mode 100644 index 0000000000..0841b4daa8 --- /dev/null +++ b/open-sse/executors/zcode.ts @@ -0,0 +1,375 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { GLM_SHARED_MODELS } from "../config/glmProvider.ts"; +import { BaseExecutor, type ExecuteInput, type ExecutorExecuteResult, type ProviderCredentials } from "./base.ts"; +import { ZcodeAppServerClient, type ZcodeClientLike } from "./zcodeProtocol.ts"; +import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; + +const ZCODE_URL = "zcode://app-server/stdio"; +const DEFAULT_PROVIDER_ID = "builtin:zai-coding-plan"; +const DEFAULT_TURN_TIMEOUT_MS = 120_000; +const DEFAULT_POLL_INTERVAL_MS = 250; +const TERMINAL_STATUSES = new Set(["completed", "idle", "paused", "error"]); +const ZCODE_MODEL_ALLOWLIST = new Set(GLM_SHARED_MODELS.map((model) => model.id)); +const DEFAULT_ZCODE_MODEL = GLM_SHARED_MODELS[0]?.id || "glm-5.2"; + +type JsonRecord = Record; +type OpenAIMsg = { role?: string; content?: unknown }; + +type ZcodeCommand = { command: string; args: string[] }; +type ZcodeModelResolution = { ok: true; model: string } | { ok: false; error: string }; + +export interface ZcodeExecutorOptions { + command?: string; + args?: string[]; + cwd?: string; + providerId?: string; + startupTimeoutMs?: number; + requestTimeoutMs?: number; + turnTimeoutMs?: number; + pollIntervalMs?: number; + clientFactory?: () => ZcodeClientLike; +} + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {}; +} + +function textFromContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => { + if (typeof part === "string") return part; + const record = asRecord(part); + if (record.type === "text" || record.type === "input_text" || record.type === "output_text") { + return typeof record.text === "string" ? record.text : ""; + } + return ""; + }) + .join(""); +} + +/** Convert an OpenAI conversation into one explicit ZCode coding turn. */ +export function buildZcodePrompt(messages: OpenAIMsg[]): string { + const parts: string[] = []; + for (const message of messages) { + const text = textFromContent(message.content).trim(); + if (!text) continue; + const role = String(message.role || "user"); + const label = role === "system" ? "System" : role === "assistant" ? "Assistant" : "User"; + parts.push(`[${label}]\n${text}`); + } + return parts.join("\n\n") || "(empty)"; +} + +export function resolveZcodeModel(model: unknown): ZcodeModelResolution { + const requested = typeof model === "string" ? model.trim() : ""; + if (!requested) return { ok: true, model: DEFAULT_ZCODE_MODEL }; + if (requested.startsWith("-")) { + return { ok: false, error: `Invalid ZCode model \"${requested}\": model must not start with \"-\".` }; + } + const normalized = requested.startsWith("zcode/") + ? requested.slice("zcode/".length) + : requested; + if (!ZCODE_MODEL_ALLOWLIST.has(normalized)) { + return { + ok: false, + error: `Unknown ZCode model \"${requested}\". Supported models: ${[...ZCODE_MODEL_ALLOWLIST].join(", ")}.`, + }; + } + return { ok: true, model: normalized }; +} + +function parseArgs(raw: string | undefined): string[] { + if (!raw) return ["app-server"]; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length > 16 || !parsed.every((arg) => typeof arg === "string" && arg.length <= 4096)) { + throw new Error("ZCODE_ARGS must be a JSON array of at most 16 strings"); + } + return parsed as string[]; +} + +function defaultCommand(): ZcodeCommand { + const runtimeRoot = process.env.ZCODE_SERVER_RUNTIME_ROOT || join(homedir(), ".zcode", "server"); + const serverNode = process.env.ZCODE_SERVER_NODE || join(runtimeRoot, "node"); + const serverEntry = process.env.ZCODE_SERVER_ENTRY || join(runtimeRoot, "zcode-server.cjs"); + if (existsSync(serverNode) && existsSync(serverEntry)) { + return { command: serverNode, args: [serverEntry] }; + } + return { command: process.env.ZCODE_BIN || "zcode", args: parseArgs(process.env.ZCODE_ARGS) }; +} + +function extractSessionId(value: unknown): string | undefined { + const root = asRecord(value); + const nested = asRecord(root.session); + const sessionId = nested.sessionId ?? root.sessionId; + return typeof sessionId === "string" && sessionId.trim() ? sessionId : undefined; +} + +function extractStatus(value: unknown): string | undefined { + const root = asRecord(value); + const nested = asRecord(root.session); + const status = nested.status ?? root.status; + return typeof status === "string" ? status : undefined; +} + +function extractTextFromMessage(value: unknown): { role?: string; text: string } { + const message = asRecord(value); + const info = asRecord(message.info); + const role = typeof info.role === "string" ? info.role : typeof message.role === "string" ? message.role : undefined; + const parts = Array.isArray(message.parts) ? message.parts : []; + const text = parts + .map((part) => { + const record = asRecord(part); + if (record.type === "text" && typeof record.text === "string") return record.text; + return ""; + }) + .join(""); + return { role, text }; +} + +function extractAssistantText(value: unknown): string { + const root = asRecord(value); + const messages = Array.isArray(root.messages) ? root.messages : []; + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = extractTextFromMessage(messages[i]); + if (message.text && (!message.role || message.role === "assistant")) return message.text; + } + const nestedMessage = extractTextFromMessage(root.message); + if (nestedMessage.text) return nestedMessage.text; + for (const candidate of [root.content, root.text, root.output_text]) { + if (typeof candidate === "string" && candidate.trim()) return candidate; + } + return ""; +} + +function extractErrorMessage(value: unknown): string { + const root = asRecord(value); + const nested = asRecord(root.error); + for (const candidate of [nested.message, root.message, root.reason]) { + if (typeof candidate === "string" && candidate.trim()) return candidate; + } + return "ZCode app-server returned an error"; +} + +function makeWorkspace(cwd: string): JsonRecord { + return { workspacePath: cwd, workspaceIdentity: cwd }; +} + +function abortError(): Error { + return new Error("ZCode request aborted"); +} + +async function raceAbort(promise: Promise, signal?: AbortSignal | null): Promise { + if (!signal) return promise; + if (signal.aborted) { + promise.catch(() => undefined); + throw abortError(); + } + let onAbort: (() => void) | undefined; + const aborted = new Promise((_, reject) => { + onAbort = () => reject(abortError()); + signal.addEventListener("abort", onAbort, { once: true }); + }); + promise.catch(() => undefined); + try { + return await Promise.race([promise, aborted]); + } finally { + if (onAbort) signal.removeEventListener("abort", onAbort); + } +} + +async function delay(ms: number, signal?: AbortSignal | null): Promise { + if (ms <= 0) { + if (signal?.aborted) throw abortError(); + return; + } + await raceAbort(new Promise((resolveDelay) => { + const timer = setTimeout(resolveDelay, ms); + timer.unref?.(); + }), signal); +} + +function estimateTokens(text: string): number { + return Math.max(1, Math.ceil(text.length / 4)); +} + +function completionResponse(model: string, prompt: string, content: string): Response { + const promptTokens = estimateTokens(prompt); + const completionTokens = estimateTokens(content); + return new Response(JSON.stringify({ + id: `chatcmpl-zcode-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model, + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + estimated: true, + }, + }), { status: 200, headers: { "Content-Type": "application/json" } }); +} + +function sseResponse(model: string, content: string): Response { + const id = `chatcmpl-zcode-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const chunks = [ + { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }] }, + { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content }, finish_reason: null }] }, + { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }, + ]; + const body = `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, + }); +} + +function sseErrorResponse(status: number, message: string): Response { + const body = `data: ${JSON.stringify(buildErrorBody(status, message))}\n\ndata: [DONE]\n\n`; + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" }, + }); +} + +export class ZcodeExecutor extends BaseExecutor { + private readonly options: ZcodeExecutorOptions; + + constructor(options: ZcodeExecutorOptions = {}) { + super("zcode", { id: "zcode", baseUrl: ZCODE_URL, format: "openai" }); + this.options = options; + } + + buildUrl(): string { + return ZCODE_URL; + } + + transformRequest(): null { + return null; + } + + async execute(input: ExecuteInput): Promise { + const resolution = resolveZcodeModel(input.model); + if (!resolution.ok) { + const message = "error" in resolution ? resolution.error : "Invalid ZCode model"; + return input.stream ? sseErrorResponse(400, message) : errorResponse(400, message); + } + + const body = asRecord(input.body); + const messages = Array.isArray(body.messages) ? body.messages as OpenAIMsg[] : []; + const prompt = buildZcodePrompt(messages); + input.log?.info?.("ZCODE", `local app-server turn started model=${resolution.model}`); + + try { + const content = await this.runTurn(resolution.model, prompt, input.signal, input.log); + const response = input.stream + ? sseResponse(resolution.model, content) + : completionResponse(resolution.model, prompt, content); + return { + response, + url: ZCODE_URL, + headers: {}, + transformedBody: { model: resolution.model, promptLength: prompt.length, buffered: true }, + transport: "local-zcode-app-server", + }; + } catch (error) { + const message = sanitizeErrorMessage(error instanceof Error ? error.message : String(error)); + input.log?.warn?.("ZCODE", message); + return input.stream ? sseErrorResponse(502, message) : errorResponse(502, message); + } + } + + private createClient(): ZcodeClientLike { + if (this.options.clientFactory) return this.options.clientFactory(); + const command = this.options.command || process.env.ZCODE_SERVER_NODE || defaultCommand().command; + const args = this.options.args || (process.env.ZCODE_SERVER_NODE + ? [process.env.ZCODE_SERVER_ENTRY || join(process.env.ZCODE_SERVER_RUNTIME_ROOT || join(homedir(), ".zcode", "server"), "zcode-server.cjs")] + : defaultCommand().args); + return new ZcodeAppServerClient({ + command, + args, + cwd: this.options.cwd || process.env.ZCODE_CWD || process.cwd(), + startupTimeoutMs: this.options.startupTimeoutMs ?? Number(process.env.ZCODE_STARTUP_TIMEOUT_MS || 10_000), + requestTimeoutMs: this.options.requestTimeoutMs ?? Number(process.env.ZCODE_RPC_TIMEOUT_MS || 30_000), + }); + } + + private async runTurn( + model: string, + prompt: string, + signal: AbortSignal | null | undefined, + log: ExecuteInput["log"] + ): Promise { + const client = this.createClient(); + const cwd = resolve(this.options.cwd || process.env.ZCODE_CWD || process.cwd()); + const workspace = makeWorkspace(cwd); + const providerId = this.options.providerId || process.env.ZCODE_PROVIDER_ID || DEFAULT_PROVIDER_ID; + const turnTimeoutMs = this.options.turnTimeoutMs ?? Number(process.env.ZCODE_TURN_TIMEOUT_MS || DEFAULT_TURN_TIMEOUT_MS); + const pollIntervalMs = this.options.pollIntervalMs ?? Number(process.env.ZCODE_POLL_INTERVAL_MS || DEFAULT_POLL_INTERVAL_MS); + let sessionId: string | undefined; + + try { + await raceAbort(client.start(), signal); + const initialized = asRecord(await raceAbort(client.call("zcode-agent", "initialize", [workspace]), signal)); + if (initialized.available !== true) { + throw new Error(extractErrorMessage(initialized)); + } + + const created = await raceAbort(client.call("zcode-agent", "createSession", [{ + ...workspace, + sessionTraceId: randomUUID(), + mode: "build", + persistence: "persistent", + }]), signal); + sessionId = extractSessionId(created); + if (!sessionId) throw new Error("ZCode createSession returned no sessionId"); + + await raceAbort(client.call("zcode-agent", "setModel", [{ + ...workspace, + sessionId, + model: { providerId, modelId: model }, + }]), signal); + + let state: unknown = await raceAbort(client.call("zcode-agent", "sendPrompt", [{ + ...workspace, + sessionId, + inputId: randomUUID(), + content: prompt, + }]), signal); + const deadline = Date.now() + Math.max(1, turnTimeoutMs); + + while (Date.now() <= deadline) { + if (signal?.aborted) throw abortError(); + const text = extractAssistantText(state); + const status = extractStatus(state); + if (text && (status === undefined || TERMINAL_STATUSES.has(status))) return text; + if (status === "error") throw new Error(extractErrorMessage(state)); + await delay(Math.max(0, pollIntervalMs), signal); + state = await raceAbort(client.call("zcode-agent", "readSession", [{ + ...workspace, + sessionId, + messageLimit: 200, + }]), signal); + } + const finalText = extractAssistantText(state); + if (finalText) return finalText; + throw new Error("ZCode turn timed out before an assistant response was available"); + } finally { + if (sessionId && !signal?.aborted) { + await client.call("zcode-agent", "closeSession", [{ ...workspace, sessionId }]).catch(() => undefined); + } + await client.close().catch((error) => log?.debug?.("ZCODE", `app-server close failed: ${sanitizeErrorMessage(error)}`)); + } + } + + // Credentials are intentionally ignored: the local ZCode profile owns auth. + override buildHeaders(_credentials: ProviderCredentials): Record { + return {}; + } +} diff --git a/open-sse/executors/zcodeProtocol.ts b/open-sse/executors/zcodeProtocol.ts new file mode 100644 index 0000000000..12a5cd1a0e --- /dev/null +++ b/open-sse/executors/zcodeProtocol.ts @@ -0,0 +1,438 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +const HEADER_SIZE = 13; +const REGULAR_MESSAGE = 1; +const INITIALIZE_MESSAGE = 200; +const RESPONSE_MESSAGE = 201; +const ERROR_MESSAGE = 202; +const CANCELED_MESSAGE = 203; +const MAX_FRAME_BYTES = 32 * 1024 * 1024; + +type JsonRecord = Record; + +export interface ZcodeAppServerClientOptions { + command: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + startupTimeoutMs?: number; + requestTimeoutMs?: number; +} + +export interface ZcodeClientLike { + start(): Promise; + call(channel: string, method: string, args: unknown[]): Promise; + close(): Promise; +} + +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +interface DecodedValue { + value: unknown; + offset: number; +} + +function encodeVql(value: number): Buffer { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`ZCode protocol requires a non-negative integer, got ${String(value)}`); + } + const bytes: number[] = []; + let remaining = value; + do { + let next = remaining % 128; + remaining = Math.floor(remaining / 128); + if (remaining > 0) next |= 0x80; + bytes.push(next); + } while (remaining > 0); + return Buffer.from(bytes); +} + +function decodeVql(data: Uint8Array, offset: number): { value: number; offset: number } { + let value = 0; + let multiplier = 1; + let cursor = offset; + for (let i = 0; i < 8; i += 1) { + if (cursor >= data.byteLength) throw new Error("Truncated ZCode variable-length quantity"); + const next = data[cursor++]; + value += (next & 0x7f) * multiplier; + if ((next & 0x80) === 0) return { value, offset: cursor }; + multiplier *= 128; + } + throw new Error("Invalid ZCode variable-length quantity"); +} + +/** Serialize one value using ZCode's SocketProtocol value encoding. */ +export function encodeZcodeValue(value: unknown): Buffer { + if (value === undefined) return Buffer.from([0]); + if (typeof value === "string") { + const bytes = Buffer.from(value, "utf8"); + return Buffer.concat([Buffer.from([1]), encodeVql(bytes.byteLength), bytes]); + } + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + const bytes = Buffer.from(value); + return Buffer.concat([Buffer.from([2]), encodeVql(bytes.byteLength), bytes]); + } + if (Array.isArray(value)) { + return Buffer.concat([ + Buffer.from([4]), + encodeVql(value.length), + ...value.map((item) => encodeZcodeValue(item)), + ]); + } + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return Buffer.concat([Buffer.from([6]), encodeVql(value)]); + } + if (typeof value === "bigint" || typeof value === "function" || typeof value === "symbol") { + throw new Error(`Unsupported ZCode protocol value type: ${typeof value}`); + } + const bytes = Buffer.from(JSON.stringify(value), "utf8"); + return Buffer.concat([Buffer.from([5]), encodeVql(bytes.byteLength), bytes]); +} + +/** Decode one value from ZCode's SocketProtocol value encoding. */ +export function decodeZcodeValue(data: Uint8Array, offset = 0): DecodedValue { + if (offset >= data.byteLength) throw new Error("Truncated ZCode serialized value"); + const type = data[offset++]; + if (type === 0) return { value: undefined, offset }; + if (type === 1 || type === 2) { + const length = decodeVql(data, offset); + const end = length.offset + length.value; + if (end > data.byteLength) throw new Error("Truncated ZCode byte/string value"); + const bytes = data.slice(length.offset, end); + return { + value: type === 1 ? Buffer.from(bytes).toString("utf8") : Buffer.from(bytes), + offset: end, + }; + } + if (type === 4) { + const length = decodeVql(data, offset); + const values: unknown[] = []; + let cursor = length.offset; + for (let i = 0; i < length.value; i += 1) { + const decoded = decodeZcodeValue(data, cursor); + values.push(decoded.value); + cursor = decoded.offset; + } + return { value: values, offset: cursor }; + } + if (type === 5) { + const length = decodeVql(data, offset); + const end = length.offset + length.value; + if (end > data.byteLength) throw new Error("Truncated ZCode JSON value"); + return { + value: JSON.parse(Buffer.from(data.slice(length.offset, end)).toString("utf8")), + offset: end, + }; + } + if (type === 6) { + const decoded = decodeVql(data, offset); + return { value: decoded.value, offset: decoded.offset }; + } + throw new Error(`Unknown ZCode serialized value type ${type}`); +} + +export function encodeZcodeRpcCall( + id: number, + channel: string, + method: string, + args: unknown[] +): Buffer { + const body = Buffer.concat([ + encodeZcodeValue([100, id, channel, method]), + encodeZcodeValue(args), + ]); + const frame = Buffer.alloc(HEADER_SIZE + body.byteLength); + frame.writeUInt8(REGULAR_MESSAGE, 0); + frame.writeUInt32BE(0, 1); + frame.writeUInt32BE(0, 5); + frame.writeUInt32BE(body.byteLength, 9); + body.copy(frame, HEADER_SIZE); + return frame; +} + +function errorFromPayload(payload: unknown, fallback: string): Error { + if (payload && typeof payload === "object") { + const record = payload as JsonRecord; + const message = typeof record.message === "string" ? record.message : fallback; + const error = new Error(message); + if (typeof record.code === "string") Object.assign(error, { code: record.code }); + if (record.data !== undefined) Object.assign(error, { data: record.data }); + return error; + } + return new Error(fallback); +} + +/** + * Local stdio client for the ZCode app-server. The protocol starts with a JSON + * hello line and then switches to 13-byte length-prefixed binary frames. + */ +export class ZcodeAppServerClient implements ZcodeClientLike { + private readonly command: string; + private readonly args: string[]; + private readonly cwd?: string; + private readonly env?: NodeJS.ProcessEnv; + private readonly startupTimeoutMs: number; + private readonly requestTimeoutMs: number; + private child?: ChildProcessWithoutNullStreams; + private outputBuffer = Buffer.alloc(0); + private handshakeDone = false; + private ready = false; + private startPromise?: Promise; + private serverReady?: () => void; + private serverReadyError?: (error: Error) => void; + private nextRequestId = 1; + private readonly pending = new Map(); + + constructor(options: ZcodeAppServerClientOptions) { + this.command = options.command; + this.args = options.args ?? []; + this.cwd = options.cwd; + this.env = options.env; + this.startupTimeoutMs = options.startupTimeoutMs ?? 10_000; + this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000; + } + + async start(): Promise { + if (this.ready) return; + if (this.startPromise) return this.startPromise; + this.startPromise = this.startInternal().finally(() => { + this.startPromise = undefined; + }); + return this.startPromise; + } + + private async startInternal(): Promise { + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(this.command, this.args, { + cwd: this.cwd, + env: this.env ? { ...process.env, ...this.env } : process.env, + stdio: ["pipe", "pipe", "pipe"], + shell: false, + windowsHide: true, + }); + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)); + } + + this.child = child; + this.outputBuffer = Buffer.alloc(0); + this.handshakeDone = false; + this.ready = false; + child.stdin.on("error", () => { + // EPIPE is expected when timeout/abort closes an already-exited runtime. + }); + + let settled = false; + const readyPromise = new Promise((resolve, reject) => { + this.serverReady = () => { + if (settled) return; + settled = true; + resolve(); + }; + this.serverReadyError = (error) => { + if (settled) return; + settled = true; + reject(error); + }; + }); + + child.stdout.on("data", (chunk: Buffer) => this.onStdout(chunk)); + child.stderr.on("data", () => { + // ZCode stderr is intentionally not forwarded: it can contain provider + // diagnostics or credentials from the user's local runtime. + }); + child.on("error", (error) => { + this.serverReadyError?.(error); + this.rejectPending(error); + }); + child.on("exit", (code, signal) => { + const error = new Error(`ZCode app-server exited: ${code ?? signal ?? "unknown"}`); + this.ready = false; + this.handshakeDone = false; + this.serverReadyError?.(error); + this.rejectPending(error); + if (this.child === child) this.child = undefined; + }); + + try { + await this.withTimeout(readyPromise, this.startupTimeoutMs, "ZCode app-server handshake timed out"); + this.ready = true; + } catch (error) { + await this.disposeChild(child); + throw error instanceof Error ? error : new Error(String(error)); + } finally { + this.serverReady = undefined; + this.serverReadyError = undefined; + } + } + + private onStdout(chunk: Buffer): void { + this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]); + if (!this.handshakeDone) { + const newline = this.outputBuffer.indexOf(0x0a); + if (newline < 0) { + if (this.outputBuffer.byteLength > 64 * 1024) { + this.serverReadyError?.(new Error("ZCode hello line is too large")); + } + return; + } + const line = this.outputBuffer.subarray(0, newline).toString("utf8").trim(); + this.outputBuffer = this.outputBuffer.subarray(newline + 1); + let hello: unknown; + try { + hello = JSON.parse(line); + } catch { + this.serverReadyError?.(new Error("Invalid ZCode app-server hello")); + return; + } + if (!hello || typeof hello !== "object" || (hello as JsonRecord).type !== "zcode-hello") { + this.serverReadyError?.(new Error("Unexpected ZCode app-server hello")); + return; + } + const child = this.child; + if (!child) return; + child.stdin.write(`${JSON.stringify({ + type: "zcode-hello-ack", + version: "omniroute", + clientId: `omniroute-${process.pid}`, + })}\n`); + this.handshakeDone = true; + } + this.consumeFrames(); + } + + private consumeFrames(): void { + while (this.outputBuffer.byteLength >= HEADER_SIZE) { + const type = this.outputBuffer.readUInt8(0); + const length = this.outputBuffer.readUInt32BE(9); + if (length > MAX_FRAME_BYTES) { + const error = new Error("ZCode frame exceeds the configured safety limit"); + this.serverReadyError?.(error); + this.rejectPending(error); + return; + } + const frameLength = HEADER_SIZE + length; + if (this.outputBuffer.byteLength < frameLength) return; + const body = this.outputBuffer.subarray(HEADER_SIZE, frameLength); + this.outputBuffer = this.outputBuffer.subarray(frameLength); + if (type !== REGULAR_MESSAGE) continue; + try { + const header = decodeZcodeValue(body, 0); + const payload = decodeZcodeValue(body, header.offset); + this.handleMessage(header.value, payload.value); + } catch (error) { + const normalized = error instanceof Error ? error : new Error(String(error)); + this.serverReadyError?.(normalized); + this.rejectPending(normalized); + } + } + } + + private handleMessage(headerValue: unknown, payload: unknown): void { + if (!Array.isArray(headerValue)) return; + const type = headerValue[0]; + if (type === INITIALIZE_MESSAGE) { + this.serverReady?.(); + return; + } + if (type !== RESPONSE_MESSAGE && type !== ERROR_MESSAGE && type !== CANCELED_MESSAGE) return; + const requestId = headerValue[1]; + if (typeof requestId !== "number") return; + const request = this.pending.get(requestId); + if (!request) return; + this.pending.delete(requestId); + clearTimeout(request.timer); + if (type === RESPONSE_MESSAGE) { + request.resolve(payload); + } else { + request.reject(errorFromPayload( + payload, + type === ERROR_MESSAGE ? "ZCode RPC request failed" : "ZCode RPC request canceled" + )); + } + } + + async call(channel: string, method: string, args: unknown[]): Promise { + await this.start(); + const child = this.child; + if (!child || !this.ready) throw new Error("ZCode app-server is not ready"); + const requestId = this.nextRequestId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId); + reject(new Error(`ZCode RPC request timed out: ${channel}.${method}`)); + }, this.requestTimeoutMs); + timer.unref?.(); + this.pending.set(requestId, { resolve, reject, timer }); + try { + child.stdin.write(encodeZcodeRpcCall(requestId, channel, method, args)); + } catch (error) { + clearTimeout(timer); + this.pending.delete(requestId); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + async close(): Promise { + const child = this.child; + this.ready = false; + this.handshakeDone = false; + this.child = undefined; + this.serverReadyError?.(new Error("ZCode app-server closed")); + this.rejectPending(new Error("ZCode app-server closed")); + if (child) await this.disposeChild(child); + } + + private rejectPending(error: Error): void { + for (const [id, pending] of this.pending) { + clearTimeout(pending.timer); + pending.reject(error); + this.pending.delete(id); + } + } + + private async disposeChild(child: ChildProcessWithoutNullStreams): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve) => child.once("close", () => resolve())); + try { + child.stdin.end(); + } catch { + // The process may already have closed stdin. + } + if (!child.killed) child.kill("SIGTERM"); + let timer: ReturnType | undefined; + await Promise.race([ + exited, + new Promise((resolve) => { + timer = setTimeout(resolve, 1500); + timer.unref?.(); + }), + ]); + if (timer) clearTimeout(timer); + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + await exited; + } + } + + private async withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } + } +} diff --git a/src/lib/acp/registry.ts b/src/lib/acp/registry.ts index 6945762409..a1cc297b3e 100644 --- a/src/lib/acp/registry.ts +++ b/src/lib/acp/registry.ts @@ -96,6 +96,15 @@ const AGENT_DEFINITIONS: Omit[] = [ spawnArgs: ["--no-auto-commits"], protocol: "stdio", }, + { + id: "zcode", + name: "ZCode (GLM Coding Plan)", + binary: "zcode", + versionCommand: "zcode --version", + providerAlias: "zcode", + spawnArgs: ["app-server"], + protocol: "stdio", + }, { id: "opencode", name: "OpenCode", diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index d66927f96a..acdba14050 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -85,6 +85,25 @@ export const CLI_TOOLS: Record = { baseUrlSupport: "full", defaultCommand: "codex", }, + zcode: { + id: "zcode", + name: "ZCode (GLM Coding Plan)", + color: "#3B82F6", + description: "Local ZCode app-server backend; auth remains in the user's ZCode profile", + docsUrl: "https://zcode.z.ai", + configType: "custom", + category: "code", + vendor: "Z.ai", + // ZCode's app-server is a native length-prefixed protocol, not ACP. The + // zcode provider executor owns its lifecycle instead of ACP spawning it. + acpSpawnable: false, + baseUrlSupport: "none", + defaultCommand: "zcode", + notes: [ + { type: "info", text: "Uses the local ZCode app-server and its existing builtin:zai-coding-plan login." }, + { type: "warning", text: "The response is buffered until the ZCode turn completes." }, + ], + }, droid: { id: "droid", name: "Factory Droid", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 143a11a45d..05957989f0 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -38,6 +38,8 @@ export const FREE_APIKEY_PROVIDER_IDS = new Set([ // accepts an optional connection row for display/priority/testStatus tracking — // no apiKey is ever required or sent upstream. "auggie", + // zcode is a local app-server backend; auth stays in the ZCode profile. + "zcode", ]); export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean { diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index 7b7246beba..b572b68888 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.ts @@ -157,6 +157,24 @@ export const NOAUTH_PROVIDERS = { text: "Augment (Auggie CLI) requires the `auggie` binary installed and authenticated locally (`auggie login`). OmniRoute spawns it as a subprocess and never sees or stores your Augment credentials.", }, }, + zcode: { + id: "zcode", + alias: "zc", + name: "ZCode (GLM Coding Plan)", + icon: "terminal", + color: "#3B82F6", + textIcon: "ZC", + website: "https://zcode.z.ai", + noAuth: true, + hasFree: false, + serviceKinds: ["llm"], + isLocalCli: true, + authHint: + "No API key stored by OmniRoute. The local ZCode app-server uses the existing builtin:zai-coding-plan login.", + notice: { + text: "ZCode runs locally through its native app-server. OmniRoute never receives or stores the Z.ai credential.", + }, + }, aihorde: { id: "aihorde", alias: "horde", diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index bee2577555..a41cab7d8b 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -93,6 +93,17 @@ const CLI_TOOLS: Record = { }, }, }, + zcode: { + defaultCommand: "zcode", + envBinKey: "ZCODE_BIN", + requiresBinary: true, + // The app-server performs a local runtime handshake and can be slower on + // the first launch while the user's ZCode profile is loaded. + healthcheckTimeoutMs: 15000, + paths: { + config: ".zcode", + }, + }, cline: { defaultCommand: "cline", envBinKey: "CLI_CLINE_BIN", diff --git a/tests/fixtures/fake-zcode-app-server.mjs b/tests/fixtures/fake-zcode-app-server.mjs new file mode 100644 index 0000000000..9996caf06f --- /dev/null +++ b/tests/fixtures/fake-zcode-app-server.mjs @@ -0,0 +1,167 @@ +const HEADER_SIZE = 13; +let input = Buffer.alloc(0); +let handshaken = false; +let sessionId = "fake-zcode-session"; +let selectedModel = null; + +function vql(value) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid vql value"); + const bytes = []; + let remaining = value; + do { + let next = remaining % 128; + remaining = Math.floor(remaining / 128); + if (remaining > 0) next |= 0x80; + bytes.push(next); + } while (remaining > 0); + return Buffer.from(bytes); +} + +function encode(value) { + if (value === undefined) return Buffer.from([0]); + if (typeof value === "string") { + const bytes = Buffer.from(value, "utf8"); + return Buffer.concat([Buffer.from([1]), vql(bytes.length), bytes]); + } + if (Buffer.isBuffer(value) || value instanceof Uint8Array) { + const bytes = Buffer.from(value); + return Buffer.concat([Buffer.from([2]), vql(bytes.length), bytes]); + } + if (Array.isArray(value)) { + return Buffer.concat([Buffer.from([4]), vql(value.length), ...value.map(encode)]); + } + if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) { + return Buffer.concat([Buffer.from([6]), vql(value)]); + } + const bytes = Buffer.from(JSON.stringify(value), "utf8"); + return Buffer.concat([Buffer.from([5]), vql(bytes.length), bytes]); +} + +function readVql(data, state) { + let value = 0; + let multiplier = 1; + for (let i = 0; i < 8; i += 1) { + if (state.offset >= data.length) throw new Error("truncated vql"); + const next = data[state.offset++]; + value += (next & 0x7f) * multiplier; + if ((next & 0x80) === 0) return value; + multiplier *= 128; + } + throw new Error("invalid vql"); +} + +function decode(data, state) { + const type = data[state.offset++]; + if (type === 0) return undefined; + if (type === 1 || type === 2) { + const length = readVql(data, state); + const end = state.offset + length; + if (end > data.length) throw new Error("truncated bytes"); + const bytes = data.subarray(state.offset, end); + state.offset = end; + return type === 1 ? bytes.toString("utf8") : bytes; + } + if (type === 4) { + const length = readVql(data, state); + return Array.from({ length }, () => decode(data, state)); + } + if (type === 5) { + const length = readVql(data, state); + const end = state.offset + length; + const value = JSON.parse(data.subarray(state.offset, end).toString("utf8")); + state.offset = end; + return value; + } + if (type === 6) return readVql(data, state); + throw new Error(`unknown type ${type}`); +} + +function frame(body) { + const result = Buffer.alloc(HEADER_SIZE + body.length); + result.writeUInt8(1, 0); + result.writeUInt32BE(0, 1); + result.writeUInt32BE(0, 5); + result.writeUInt32BE(body.length, 9); + body.copy(result, HEADER_SIZE); + return result; +} + +function send(header, payload) { + const packet = frame(Buffer.concat([encode(header), encode(payload)])); + process.stdout.write(packet.subarray(0, 5)); + setTimeout(() => process.stdout.write(packet.subarray(5)), 1); +} + +function response(id, payload) { + send([201, id], payload); +} + +function handleFrame(body) { + const state = { offset: 0 }; + const header = decode(body, state); + const args = decode(body, state); + const id = Array.isArray(header) ? header[1] : undefined; + const method = Array.isArray(header) ? header[3] : undefined; + const request = Array.isArray(args) && args[0] && typeof args[0] === "object" ? args[0] : {}; + + switch (method) { + case "initialize": + response(id, { available: true, protocolName: "ZCode Protocol", protocolVersion: 1, transportKind: "stdio" }); + break; + case "createSession": + sessionId = "fake-zcode-session"; + response(id, { session: { sessionId, status: "idle", workspace: { workspacePath: request.workspacePath } }, messages: [] }); + break; + case "setModel": + selectedModel = request.model; + response(id, { ok: true, model: selectedModel }); + break; + case "sendPrompt": + response(id, { session: { sessionId, status: "running" }, accepted: true }); + break; + case "readSession": + response(id, { + session: { sessionId, status: "completed", model: selectedModel }, + messages: [ + { info: { messageId: "fake-user-message", role: "user" }, parts: [{ type: "text", text: request.content || "prompt" }] }, + { info: { messageId: "fake-assistant-message", role: "assistant" }, parts: [{ type: "text", text: "fake zcode response" }] }, + ], + }); + break; + case "closeSession": + response(id, { ok: true }); + break; + default: + response(id, { ok: true }); + break; + } +} + +function consumeFrames() { + while (input.length >= HEADER_SIZE) { + const length = input.readUInt32BE(9); + const total = HEADER_SIZE + length; + if (input.length < total) return; + const body = input.subarray(HEADER_SIZE, total); + input = input.subarray(total); + handleFrame(body); + } +} + +process.stdout.write(`${JSON.stringify({ type: "zcode-hello", version: "fixture", platform: "test", arch: "test", pid: process.pid })}\n`); + +process.stdin.on("data", (chunk) => { + input = Buffer.concat([input, chunk]); + if (!handshaken) { + const newline = input.indexOf(0x0a); + if (newline < 0) return; + const ack = JSON.parse(input.subarray(0, newline).toString("utf8")); + if (ack.type !== "zcode-hello-ack") throw new Error("missing ZCode hello ack"); + input = input.subarray(newline + 1); + handshaken = true; + send([200], undefined); + } + consumeFrames(); +}); + +process.stdin.on("end", () => process.exit(0)); diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 699c8acbd4..10db04e9cd 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -6156,6 +6156,29 @@ "stream": "https://chat.z.ai" } }, + "zcode": { + "format": "openai", + "headers": { + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json" + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json" + } + }, + "url": { + "nonStream": "zcode://app-server/stdio", + "stream": "zcode://app-server/stdio" + } + }, "zed-hosted": { "format": "openai", "headers": { diff --git a/tests/unit/autoCombo/provider-family-combos.test.ts b/tests/unit/autoCombo/provider-family-combos.test.ts index 45c28c3a30..3a7e33e4f2 100644 --- a/tests/unit/autoCombo/provider-family-combos.test.ts +++ b/tests/unit/autoCombo/provider-family-combos.test.ts @@ -141,7 +141,10 @@ describe("auto/ materialization (#6453)", () => { // `devin-cli-agentic` joined for the same documented reason as `auggie`: // #8914 added the Devin ACP bridge whose catalog (registry/devin/catalog.ts) // advertises the glm-5-2* line, so it genuinely serves the family. - assert.deepEqual(providerIds, ["auggie", "devin-cli-agentic", "glm", "zai"]); + // `zcode` joined for the same documented reason too — #10184 added the local + // ZCode app-server backend whose registry (registry/zcode) advertises the + // full GLM_SHARED_MODELS line-up, so it genuinely serves the family. + assert.deepEqual(providerIds, ["auggie", "devin-cli-agentic", "glm", "zai", "zcode"]); // Every candidate must be a glm-family model (the Cartesian pool now surfaces // each backend's full glm line-up, not only the glm-5.2 default), and the // connected openai/gpt-4o-mini backend must be excluded — same family diff --git a/tests/unit/zcode-executor.test.ts b/tests/unit/zcode-executor.test.ts new file mode 100644 index 0000000000..c82e7b3368 --- /dev/null +++ b/tests/unit/zcode-executor.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const fixture = join(process.cwd(), "tests/fixtures/fake-zcode-app-server.mjs"); +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-zcode-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +test.after(() => rmSync(TEST_DATA_DIR, { recursive: true, force: true })); + +async function loadZcodeExecutor() { + return import("../../open-sse/executors/zcode.ts"); +} + +function requestBody() { + return { + messages: [ + { role: "system", content: "You are a coding assistant." }, + { role: "user", content: "Reply with a short status." }, + ], + }; +} + +test("ZCode accepts GLM Coding Plan models and rejects unsafe/unknown ids", async () => { + const { resolveZcodeModel } = await loadZcodeExecutor(); + assert.deepEqual(resolveZcodeModel("glm-5.2"), { ok: true, model: "glm-5.2" }); + assert.equal(resolveZcodeModel("-unexpected").ok, false); + assert.equal(resolveZcodeModel("unknown-model").ok, false); +}); + +test("ZCode runs a local app-server turn and returns an OpenAI chat completion", async () => { + const { ZcodeExecutor } = await loadZcodeExecutor(); + const executor = new ZcodeExecutor({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + requestTimeoutMs: 3000, + turnTimeoutMs: 3000, + pollIntervalMs: 1, + }); + + const result = await executor.execute({ + model: "glm-5.2", + body: requestBody(), + stream: false, + credentials: {}, + }); + const response = "response" in result ? result.response : result; + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") || "", /application\/json/); + const body = await response.json(); + assert.equal(body.object, "chat.completion"); + assert.equal(body.model, "glm-5.2"); + assert.equal(body.choices?.[0]?.message?.role, "assistant"); + assert.equal(body.choices?.[0]?.message?.content, "fake zcode response"); + assert.equal(body.choices?.[0]?.finish_reason, "stop"); +}); + +test("ZCode buffers the completed turn into OpenAI SSE when stream=true", async () => { + const { ZcodeExecutor } = await loadZcodeExecutor(); + const executor = new ZcodeExecutor({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + requestTimeoutMs: 3000, + turnTimeoutMs: 3000, + pollIntervalMs: 1, + }); + + const result = await executor.execute({ + model: "glm-5.2-high", + body: requestBody(), + stream: true, + credentials: {}, + }); + const response = "response" in result ? result.response : result; + const text = await response.text(); + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") || "", /text\/event-stream/); + assert.match(text, /fake zcode response/); + assert.match(text, /data: \[DONE\]/); +}); diff --git a/tests/unit/zcode-protocol.test.ts b/tests/unit/zcode-protocol.test.ts new file mode 100644 index 0000000000..6f40c6f9fd --- /dev/null +++ b/tests/unit/zcode-protocol.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { join } from "node:path"; +import { ZcodeAppServerClient } from "../../open-sse/executors/zcodeProtocol.ts"; + +const fixture = join(process.cwd(), "tests/fixtures/fake-zcode-app-server.mjs"); + +test("ZCode protocol performs hello handshake and exchanges fragmented framed RPC", async () => { + const client = new ZcodeAppServerClient({ + command: process.execPath, + args: [fixture], + cwd: process.cwd(), + startupTimeoutMs: 3000, + requestTimeoutMs: 3000, + }); + try { + await client.start(); + const result = await client.call("zcode-agent", "initialize", [ + { workspacePath: "/workspace", workspaceIdentity: "/workspace" }, + ]); + assert.deepEqual(result, { + available: true, + protocolName: "ZCode Protocol", + protocolVersion: 1, + transportKind: "stdio", + }); + } finally { + await client.close(); + } +}); diff --git a/tests/unit/zcode-provider.test.ts b/tests/unit/zcode-provider.test.ts new file mode 100644 index 0000000000..3acf3c862c --- /dev/null +++ b/tests/unit/zcode-provider.test.ts @@ -0,0 +1,14 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { zcodeProvider } from "../../open-sse/config/providers/registry/zcode/index.ts"; + +test("ZCode provider registry exposes a local no-auth GLM Coding Plan backend", () => { + assert.equal(zcodeProvider.id, "zcode"); + assert.equal(zcodeProvider.alias, "zc"); + assert.equal(zcodeProvider.executor, "zcode"); + assert.equal(zcodeProvider.format, "openai"); + assert.equal(zcodeProvider.baseUrl, "zcode://app-server/stdio"); + assert.equal(zcodeProvider.authType, "none"); + assert.equal(zcodeProvider.authHeader, "none"); + assert.equal(zcodeProvider.models.some((model) => model.id === "glm-5.2"), true); +}); From 5239728d6f481f7cbd27c0f6e93416b9e4f60e9d Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 16 Aug 2026 12:13:41 +0900 Subject: [PATCH 02/36] feat(providers): add Grok 4.6 and refresh DeepSeek V4 (#10195) --- docs/guides/CODEX-CLI-CONFIGURATION.md | 6 +- .../providers/registry/deepseek/index.ts | 25 ++++-- .../providers/registry/grok-cli/index.ts | 9 ++ .../config/providers/registry/xai/index.ts | 11 +++ open-sse/handlers/chatCore/targetFormat.ts | 14 ++- src/shared/constants/pricing/frontier-labs.ts | 19 ++-- tests/snapshots/provider/translate-path.json | 6 +- tests/unit/alternate-formats.test.ts | 89 ++++++++++++++++++- tests/unit/chat-route-coverage.test.ts | 24 ++++- tests/unit/chatcore-target-format.test.ts | 12 +++ tests/unit/chatcore-translation-paths.test.ts | 14 +-- tests/unit/executor-xai.test.ts | 16 ++++ tests/unit/grok-cli-responses-compat.test.ts | 10 ++- 13 files changed, 226 insertions(+), 29 deletions(-) diff --git a/docs/guides/CODEX-CLI-CONFIGURATION.md b/docs/guides/CODEX-CLI-CONFIGURATION.md index 943ca86607..0749637fe5 100644 --- a/docs/guides/CODEX-CLI-CONFIGURATION.md +++ b/docs/guides/CODEX-CLI-CONFIGURATION.md @@ -79,7 +79,7 @@ Use a real key instead when your OmniRoute server is protected or remote. Codex CLI deprecated `wire_api = "chat"` (Chat Completions) in February 2026 and now **requires** `wire_api = "responses"` (OpenAI Responses API). Setting `wire_api = "chat"` causes an immediate startup crash since v0.138. -DeepSeek, GLM, Kimi and others only expose a Chat Completions endpoint — not the Responses API. If you pointed Codex directly at them, it would fail. +Many providers, including GLM and Kimi, still expose only a Chat Completions endpoint. DeepSeek V4 now exposes a native Responses API as well as an Anthropic-compatible endpoint; OmniRoute uses Responses by default and lets each DeepSeek connection select Anthropic compatibility. **OmniRoute solves this transparently:** @@ -87,8 +87,8 @@ DeepSeek, GLM, Kimi and others only expose a Chat Completions endpoint — not t Codex CLI → wire_api = "responses" → POST /v1/responses (OmniRoute) - → OmniRoute Responses ↔ Chat Completions transformer - → POST /chat/completions (DeepSeek / Mistral / GLM / Kimi / any provider) + → OmniRoute selects the provider's native protocol and translates when needed + → POST /responses (DeepSeek V4) or /chat/completions (Mistral / GLM / Kimi / others) ``` You never need a separate translation proxy when using OmniRoute. **All models use `wire_api = "responses"`** — OmniRoute handles the rest. diff --git a/open-sse/config/providers/registry/deepseek/index.ts b/open-sse/config/providers/registry/deepseek/index.ts index 6825b23078..933fb9bba1 100644 --- a/open-sse/config/providers/registry/deepseek/index.ts +++ b/open-sse/config/providers/registry/deepseek/index.ts @@ -1,25 +1,40 @@ -import type { RegistryEntry } from "../../shared.ts"; +import { getAnthropicCompatHeaders, type RegistryEntry } from "../../shared.ts"; export const deepseekProvider: RegistryEntry = { id: "deepseek", alias: "ds", - format: "openai", + format: "openai-responses", executor: "default", - baseUrl: "https://api.deepseek.com/v1/chat/completions", + baseUrl: "https://api.deepseek.com/responses", authType: "apikey", authHeader: "bearer", + alternateFormats: [ + { + format: "claude", + baseUrl: "https://api.deepseek.com/anthropic/v1/messages", + authHeader: "x-api-key", + headers: getAnthropicCompatHeaders(), + label: "Anthropic-compatible", + }, + ], models: [ { id: "deepseek-v4-pro", - name: "DeepSeek V4 Pro", + name: "DeepSeek V4 Pro (0813)", + contextLength: 1_000_000, + maxOutputTokens: 384_000, supportsReasoning: true, supportedThinkingEfforts: ["none", "high", "max"], + toolCalling: true, }, { id: "deepseek-v4-flash", - name: "DeepSeek V4 Flash", + name: "DeepSeek V4 Flash (0731)", + contextLength: 1_000_000, + maxOutputTokens: 384_000, supportsReasoning: true, supportedThinkingEfforts: ["none", "low", "high", "max"], + toolCalling: true, }, ], }; diff --git a/open-sse/config/providers/registry/grok-cli/index.ts b/open-sse/config/providers/registry/grok-cli/index.ts index 75effafe78..f257f8d60a 100644 --- a/open-sse/config/providers/registry/grok-cli/index.ts +++ b/open-sse/config/providers/registry/grok-cli/index.ts @@ -20,6 +20,15 @@ export const grok_cliProvider: RegistryEntry = { authHeader: "bearer", passthroughModels: true, models: [ + { + id: "grok-4.6", + name: "Grok 4.6", + contextLength: 500000, + supportsReasoning: true, + toolCalling: true, + targetFormat: "openai-responses", + unsupportedParams: ["presencePenalty", "frequencyPenalty", "logprobs", "topLogprobs"], + }, { id: "grok-4.5", name: "Grok 4.5", diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index f33e247080..6d2cc9621a 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -14,6 +14,17 @@ export const xaiProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", models: [ + { + id: "grok-4.6", + name: "Grok 4.6", + contextLength: 500000, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "xhigh"], + supportsVision: true, + supportsXHighEffort: true, + toolCalling: true, + targetFormat: "openai-responses", + }, { id: "grok-4.3", name: "Grok 4.3" }, { id: "grok-build-0.1", name: "Grok Build 0.1", contextLength: 256000 }, // Responses-only per upstream 9router#2439: xAI serves this id exclusively diff --git a/open-sse/handlers/chatCore/targetFormat.ts b/open-sse/handlers/chatCore/targetFormat.ts index 991a5c3eb2..27ce3aa3d8 100644 --- a/open-sse/handlers/chatCore/targetFormat.ts +++ b/open-sse/handlers/chatCore/targetFormat.ts @@ -3,14 +3,17 @@ * decomposition, #3501). * * Pure resolution of the provider alias + the upstream target format used to translate the request. - * Model/custom overrides win first. A Responses-shaped inbound request normally keeps the Responses - * wire format, except for custom OpenAI-compatible connections explicitly configured for Chat. + * Model/custom overrides win first. A declared connection-level alternate protocol wins next. A + * Responses-shaped inbound request otherwise keeps the Responses wire format, except for custom + * OpenAI-compatible connections explicitly configured for Chat. * AgentRouter may inherit the inbound protocol when no explicit connection override exists. * Returns both `alias` (reused by the handler when stripping the `alias/` prefix off the upstream * model id) and `targetFormat`. */ import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../config/providerModels.ts"; +import { getRegistryEntry } from "../../config/providerRegistry.ts"; +import { resolveAlternateFormat } from "../../config/providers/alternateFormats.ts"; import { getTargetFormat } from "../../services/provider.ts"; import { FORMATS } from "../../translator/formats.ts"; @@ -46,15 +49,22 @@ export function resolveChatCoreTargetFormat(opts: { ? sourceFormat : undefined; const providerTargetFormat = getTargetFormat(provider, providerSpecificData); + const declaredConnectionAlternate = resolveAlternateFormat( + getRegistryEntry(provider), + providerSpecificData + ); const customOpenAICompatible = provider.startsWith("openai-compatible-"); // #8994: model-level targetFormat overrides (from registry or custom-model DB override) // take precedence over apiFormat="responses" — otherwise Vertex Claude models with // targetFormat="claude" get wrongly routed to OpenAI Responses format. // #9161: a custom OpenAI-compatible Chat connection must likewise keep its configured // outbound protocol when a Responses-shaped client (for example Codex) calls /responses. + // Registry-declared connection alternates are equally explicit: a DeepSeek connection set to + // Anthropic must stay on /anthropic/v1/messages even when the caller speaks Responses. let targetFormat = modelTargetFormat || customModelTargetFormat || + declaredConnectionAlternate?.format || (apiFormat === "responses" && !customOpenAICompatible ? FORMATS.OPENAI_RESPONSES : inferredAgentRouterTargetFormat || providerTargetFormat); diff --git a/src/shared/constants/pricing/frontier-labs.ts b/src/shared/constants/pricing/frontier-labs.ts index 59e580e971..a7cbf79935 100644 --- a/src/shared/constants/pricing/frontier-labs.ts +++ b/src/shared/constants/pricing/frontier-labs.ts @@ -315,20 +315,20 @@ export const DEFAULT_PRICING_FRONTIER = { reasoning: 2.19, cache_creation: 0.55, }, - // DeepSeek V4 Pro — promo until 2026-05-31, then list ($0.145 / $3.48) + // DeepSeek official API list prices, checked 2026-08-13. "deepseek-v4-pro": { input: 0.435, output: 0.87, - cached: 0.0036, + cached: 0.003625, reasoning: 0.87, cache_creation: 0.435, }, "deepseek-v4-flash": { - input: 0.07, + input: 0.14, output: 0.28, - cached: 0.014, + cached: 0.0028, reasoning: 0.28, - cache_creation: 0.07, + cache_creation: 0.14, }, }, blackbox: { @@ -340,6 +340,15 @@ export const DEFAULT_PRICING_FRONTIER = { "blackboxai-pro": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 }, }, xai: { + // The static rate covers prompts below 200K tokens. xAI's provider-reported + // cost_in_usd_ticks remains authoritative for the >=200K pricing tier. + "grok-4.6": { + input: 2.0, + output: 6.0, + cached: 0.5, + reasoning: 6.0, + cache_creation: 2.0, + }, "grok-3": { input: 3.0, output: 15.0, diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 10db04e9cd..b476020a95 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -1604,7 +1604,7 @@ } }, "deepseek": { - "format": "openai", + "format": "openai-responses", "headers": { "apiKey": { "Accept": "text/event-stream", @@ -1622,8 +1622,8 @@ } }, "url": { - "nonStream": "https://api.deepseek.com/v1/chat/completions", - "stream": "https://api.deepseek.com/v1/chat/completions" + "nonStream": "https://api.deepseek.com/responses", + "stream": "https://api.deepseek.com/responses" } }, "deepseek-web": { diff --git a/tests/unit/alternate-formats.test.ts b/tests/unit/alternate-formats.test.ts index 9aa40d7f34..c285c7f2c8 100644 --- a/tests/unit/alternate-formats.test.ts +++ b/tests/unit/alternate-formats.test.ts @@ -4,6 +4,8 @@ import { resolveAlternateFormat } from "../../open-sse/config/providers/alternat import type { RegistryEntry } from "../../open-sse/config/providers/shared.ts"; import { getTargetFormat } from "../../open-sse/services/provider.ts"; import { DefaultExecutor } from "../../open-sse/executors/default.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { translateRequest } from "../../open-sse/translator/index.ts"; import { getAlternateFormats } from "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"; const ENTRY: RegistryEntry = { @@ -42,7 +44,10 @@ test("retorna null quando a conexao nao tem targetFormat", () => { }); test("retorna null quando a entry nao declara alternativas", () => { - assert.equal(resolveAlternateFormat({ ...ENTRY, alternateFormats: undefined }, { targetFormat: "claude" }), null); + assert.equal( + resolveAlternateFormat({ ...ENTRY, alternateFormats: undefined }, { targetFormat: "claude" }), + null + ); assert.equal(resolveAlternateFormat(null, { targetFormat: "claude" }), null); }); @@ -103,7 +108,11 @@ test("resolveBaseUrl: baseUrl manual da conexao vence a alternativa", () => { }); test("resolveBaseUrl: alternativa vence o baseUrl padrao", () => { - const url = precedence({ targetFormat: "claude" }, ENTRY_WITH_ALT, "https://default.example.com/v1"); + const url = precedence( + { targetFormat: "claude" }, + ENTRY_WITH_ALT, + "https://default.example.com/v1" + ); assert.equal(url, "https://alt.example.com/anthropic/v1/messages"); }); @@ -179,7 +188,81 @@ test("getAlternateFormats: provedor com alternativas retorna a lista", () => { }); test("getAlternateFormats: provedor sem alternativas retorna lista vazia", () => { - assert.deepEqual(getAlternateFormats("deepseek"), []); + assert.deepEqual(getAlternateFormats("xai"), []); assert.deepEqual(getAlternateFormats(null), []); assert.deepEqual(getAlternateFormats(undefined), []); }); + +test("DeepSeek defaults to Responses and exposes the official Anthropic endpoint", () => { + assert.equal(getTargetFormat("deepseek", null), "openai-responses"); + assert.equal(getTargetFormat("deepseek", { targetFormat: "claude" }), "claude"); + + const defaultExecutor = new DefaultExecutor("deepseek"); + assert.equal( + defaultExecutor.buildUrl("deepseek-v4-pro", true, 0, { apiKey: "sk-test" } as never), + "https://api.deepseek.com/responses" + ); + const defaultHeaders = defaultExecutor.buildHeaders({ apiKey: "sk-test" } as never, true); + assert.equal(defaultHeaders.Authorization, "Bearer sk-test"); + + const anthropicCredentials = { + apiKey: "sk-test", + providerSpecificData: { targetFormat: "claude" }, + } as never; + assert.equal( + defaultExecutor.buildUrl("deepseek-v4-pro", true, 0, anthropicCredentials), + "https://api.deepseek.com/anthropic/v1/messages" + ); + const anthropicHeaders = defaultExecutor.buildHeaders(anthropicCredentials, true); + assert.equal(anthropicHeaders["x-api-key"], "sk-test"); + assert.equal(typeof anthropicHeaders["Anthropic-Version"], "string"); + + const alternates = getAlternateFormats("deepseek"); + assert.equal(alternates.length, 1); + assert.equal(alternates[0].format, "claude"); +}); + +test("DeepSeek reuses the generic Chat-to-Responses and Responses-to-Anthropic translators", () => { + const responsesBody = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES, + "deepseek-v4-pro", + { + model: "deepseek-v4-pro", + messages: [{ role: "user", content: "hello" }], + max_tokens: 123, + stream: true, + }, + true, + {}, + "deepseek" + ) as Record; + assert.equal(responsesBody.messages, undefined); + assert.equal(responsesBody.max_output_tokens, 123); + assert.deepEqual(responsesBody.input, [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "hello" }], + status: "completed", + }, + ]); + + const anthropicBody = translateRequest( + FORMATS.OPENAI_RESPONSES, + FORMATS.CLAUDE, + "deepseek-v4-pro", + { + model: "deepseek-v4-pro", + input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], + stream: true, + }, + true, + {}, + "deepseek" + ) as Record; + assert.equal(anthropicBody.input, undefined); + assert.deepEqual(anthropicBody.messages, [ + { role: "user", content: [{ type: "text", text: "hello" }] }, + ]); +}); diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index b7174d6358..e0b71badc9 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -202,6 +202,7 @@ test("handleChat rejects requests without a model", async () => { test("handleChat applies task-aware routing when a semantic override is enabled", async () => { await seedConnection("deepseek", { apiKey: "sk-deepseek-task-route" }); const seenAuthHeaders = []; + const seenRequestBodies = []; setTaskRoutingConfig({ enabled: true, detectionEnabled: true, @@ -214,7 +215,26 @@ test("handleChat applies task-aware routing when a semantic override is enabled" globalThis.fetch = async (_url, init = {}) => { const headers = toPlainHeaders(init.headers); seenAuthHeaders.push(headers.Authorization ?? headers.authorization); - return buildOpenAIResponse("Task-routed response", "deepseek/deepseek-chat"); + seenRequestBodies.push(JSON.parse(String(init.body))); + return new Response( + JSON.stringify({ + id: "resp_task_route", + object: "response", + status: "completed", + model: "deepseek-v4-flash", + output: [ + { + id: "msg_task_route", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "Task-routed response", annotations: [] }], + }, + ], + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); }; const response = await handleChat( @@ -230,6 +250,8 @@ test("handleChat applies task-aware routing when a semantic override is enabled" assert.equal(response.status, 200); assert.deepEqual(seenAuthHeaders, ["Bearer sk-deepseek-task-route"]); + assert.equal(seenRequestBodies[0].messages, undefined); + assert.equal(seenRequestBodies[0].input[0].role, "user"); assert.equal(json.choices[0].message.content, "Task-routed response"); }); diff --git a/tests/unit/chatcore-target-format.test.ts b/tests/unit/chatcore-target-format.test.ts index 4eb22d5598..b820b245fe 100644 --- a/tests/unit/chatcore-target-format.test.ts +++ b/tests/unit/chatcore-target-format.test.ts @@ -115,6 +115,18 @@ test("#8994: customModelTargetFormat takes precedence over apiFormat='responses' assert.equal(r.targetFormat, "claude", "model-level targetFormat must win over apiFormat"); }); +test("a declared connection alternate overrides the inbound Responses protocol", () => { + const r = resolveChatCoreTargetFormat({ + provider: "deepseek", + resolvedModel: "deepseek-v4-pro", + apiFormat: "responses", + sourceFormat: FORMATS.OPENAI_RESPONSES, + customModelTargetFormat: undefined, + providerSpecificData: { targetFormat: FORMATS.CLAUDE }, + }); + assert.equal(r.targetFormat, FORMATS.CLAUDE); +}); + test("unmapped provider → alias falls back to the provider id", () => { const r = resolveChatCoreTargetFormat({ provider: "some-unmapped-provider", diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 397f3f50d6..c82d6987b2 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -607,8 +607,10 @@ test("chatCore applies Responses input policy to openai-compatible targets", asy }); test("chatCore replays no-tool reasoning across public Responses turns", async () => { + // Direct DeepSeek now speaks Responses upstream. Keep this regression on a + // Chat-compatible DeepSeek host so it continues to exercise the Responses-to-Chat replay path. saveModelsDevCapabilities({ - deepseek: { + siliconflow: { "deepseek-v4-pro": { ...capabilityEntry(128_000), reasoning: true, @@ -640,7 +642,7 @@ test("chatCore replays no-tool reasoning across public Responses turns", async ( ); const first = await invokeChatCore({ - provider: "deepseek", + provider: "siliconflow", model: "deepseek-v4-pro", endpoint: "/v1/responses", body: { @@ -656,7 +658,7 @@ test("chatCore replays no-tool reasoning across public Responses turns", async ( assert.equal(first.result.success, true); const second = await invokeChatCore({ - provider: "deepseek", + provider: "siliconflow", model: "deepseek-v4-pro", endpoint: "/v1/responses", body: { @@ -687,7 +689,7 @@ test("chatCore replays no-tool reasoning across public Responses turns", async ( }); test("chatCore captures streaming no-tool reasoning for Responses replay", async () => { saveModelsDevCapabilities({ - deepseek: { + siliconflow: { "deepseek-v4-pro": { ...capabilityEntry(128_000), reasoning: true, @@ -730,7 +732,7 @@ test("chatCore captures streaming no-tool reasoning for Responses replay", async ); const first = await invokeChatCore({ - provider: "deepseek", + provider: "siliconflow", model: "deepseek-v4-pro", endpoint: "/v1/responses", body: { @@ -748,7 +750,7 @@ test("chatCore captures streaming no-tool reasoning for Responses replay", async await flushAsyncSideEffects(); const second = await invokeChatCore({ - provider: "deepseek", + provider: "siliconflow", model: "deepseek-v4-pro", endpoint: "/v1/responses", body: { diff --git a/tests/unit/executor-xai.test.ts b/tests/unit/executor-xai.test.ts index 5d4d05fa74..4668efd22e 100644 --- a/tests/unit/executor-xai.test.ts +++ b/tests/unit/executor-xai.test.ts @@ -6,6 +6,7 @@ import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/in import { xaiProvider } from "../../open-sse/config/providers/registry/xai/index.ts"; // Real xai catalog ids (open-sse/config/providers/registry/xai/index.ts): +// grok-4.6 — Responses-first flagship with vision + reasoning // grok-4.3 — plain, reasoning-capable // grok-build-0.1 — build/tool model, no reasoning mode // grok-4.20-multi-agent-0309 — neutral (not in either allow/deny list) @@ -28,6 +29,21 @@ test("XaiExecutor can target the separate xAI OAuth provider config", () => { assert.equal(executor.buildUrl("grok-4.5", false), "https://api.x.ai/v1/responses"); }); +test("Grok 4.6 advertises its official capabilities and uses native Responses", () => { + const model = xaiProvider.models.find((entry) => entry.id === "grok-4.6"); + assert.ok(model); + assert.equal(model.contextLength, 500000); + assert.equal(model.supportsVision, true); + assert.equal(model.supportsReasoning, true); + assert.equal(model.toolCalling, true); + assert.equal(model.supportsXHighEffort, true); + assert.deepEqual(model.supportedThinkingEfforts, ["low", "medium", "high", "xhigh"]); + assert.equal(model.targetFormat, "openai-responses"); + + const executor = new XaiExecutor(); + assert.equal(executor.buildUrl("grok-4.6", true), "https://api.x.ai/v1/responses"); +}); + test("strips a -{level} suffix from an allow-listed model and sets reasoning_effort", () => { const executor = new XaiExecutor(); diff --git a/tests/unit/grok-cli-responses-compat.test.ts b/tests/unit/grok-cli-responses-compat.test.ts index ad9888fe94..3af78305b7 100644 --- a/tests/unit/grok-cli-responses-compat.test.ts +++ b/tests/unit/grok-cli-responses-compat.test.ts @@ -22,6 +22,12 @@ test("grok-cli exposes the authenticated grok-build model catalog", () => { targetFormat, })), [ + { + id: "grok-4.6", + name: "Grok 4.6", + contextLength: 500000, + targetFormat: "openai-responses", + }, { id: "grok-4.5", name: "Grok 4.5", @@ -36,13 +42,15 @@ test("grok-cli exposes the authenticated grok-build model catalog", () => { }, ] ); + assert.equal(getModelTargetFormat("gc", "grok-4.6"), "openai-responses"); assert.equal(getModelTargetFormat("gc", "grok-4.5"), "openai-responses"); assert.equal(getModelTargetFormat("gc", "grok-composer-2.5-fast"), "openai-responses"); assert.equal(grok_cliProvider.modelsUrl, GROK_BUILD_MODELS_URL); }); -test("grok-cli routes both models to the Responses endpoint", () => { +test("grok-cli routes its catalog models to the Responses endpoint", () => { const executor = new GrokCliExecutor(); + assert.equal(executor.buildUrl("grok-4.6", true), "https://cli-chat-proxy.grok.com/v1/responses"); assert.equal(executor.buildUrl("grok-4.5", true), "https://cli-chat-proxy.grok.com/v1/responses"); assert.equal( executor.buildUrl("grok-composer-2.5-fast", false), From 5a7487a60a3e21d1527dfbcc98612804a865fcb5 Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 16 Aug 2026 12:13:46 +0900 Subject: [PATCH 03/36] refactor(providers): unify xAI authentication entry point (#10201) Present xAI API-key and OAuth connections through one dashboard card while preserving the distinct backend IDs required for refresh and quota handling. Co-locate both registry entries and include canonical and legacy connection IDs in provider fetch and batch-test flows. --- open-sse/config/providers/index.ts | 3 +- .../providers/registry/xai-oauth/index.ts | 33 -------------- .../config/providers/registry/xai/index.ts | 38 ++++++++++++++++ .../[id]/ProviderDetailPageClient.tsx | 11 ++++- .../[id]/components/ProviderModalsPanel.tsx | 6 ++- .../__tests__/dual-auth-actions.test.tsx | 9 ++++ .../dashboard/providers/providerPageUtils.ts | 20 +++++---- src/app/api/providers/test-batch/route.ts | 4 +- src/lib/providers/catalog.ts | 2 + src/shared/constants/providers.ts | 27 ++++++++++-- .../providers/apikey/frontier-labs.ts | 7 +++ src/shared/constants/providers/oauth.ts | 3 ++ src/shared/validation/providerSchema.ts | 1 + ...rovider-connections-fetch-url-2998.test.ts | 4 ++ tests/unit/providers-page-utils.test.ts | 44 +++++++++++++++++++ tests/unit/xai-oauth-provider.test.ts | 2 +- 16 files changed, 162 insertions(+), 52 deletions(-) delete mode 100644 open-sse/config/providers/registry/xai-oauth/index.ts diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index ff09ad02b6..91584babc9 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -145,8 +145,7 @@ import { vertex_partnerProvider } from "./registry/vertex/partner/index.ts"; import { vertexProvider } from "./registry/vertex/index.ts"; import { duckduckgo_webProvider } from "./registry/duckduckgo-web/index.ts"; import { felo_webProvider } from "./registry/felo-web/index.ts"; -import { xaiProvider } from "./registry/xai/index.ts"; -import { xai_oauthProvider } from "./registry/xai-oauth/index.ts"; +import { xaiProvider, xai_oauthProvider } from "./registry/xai/index.ts"; import { morphProvider } from "./registry/morph/index.ts"; import { siliconflowProvider } from "./registry/siliconflow/index.ts"; import { gitlab_duoProvider } from "./registry/gitlab-duo/index.ts"; diff --git a/open-sse/config/providers/registry/xai-oauth/index.ts b/open-sse/config/providers/registry/xai-oauth/index.ts deleted file mode 100644 index cd644a707e..0000000000 --- a/open-sse/config/providers/registry/xai-oauth/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { RegistryEntry } from "../../shared.ts"; -import { resolvePublicCred } from "../../shared.ts"; -import { xaiProvider } from "../xai/index.ts"; - -export const xai_oauthProvider: RegistryEntry = { - id: "xai-oauth", - alias: "xao", - format: "openai", - executor: "xai-oauth", - baseUrl: xaiProvider.baseUrl, - responsesBaseUrl: xaiProvider.responsesBaseUrl, - authType: "oauth", - authHeader: "bearer", - passthroughModels: true, - oauth: { - clientIdEnv: "GROK_OAUTH_CLIENT_ID", - clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), - tokenUrl: "https://auth.x.ai/oauth2/token", - }, - models: [ - // SuperGrok / xAI OAuth serves grok-4.5 on native /v1/responses. Tag so - // chatCore translates OpenAI Chat Completions → Responses (messages→input, - // max_tokens→max_output_tokens). Without the tag, some 3.8.50 paths hit - // /v1/responses with a chat-shaped body → 422 missing `input` (#10165). - { - id: "grok-4.5", - name: "Grok 4.5", - contextLength: 500000, - targetFormat: "openai-responses", - }, - ...(xaiProvider.models || []), - ], -}; diff --git a/open-sse/config/providers/registry/xai/index.ts b/open-sse/config/providers/registry/xai/index.ts index 6d2cc9621a..efd0a72e3a 100644 --- a/open-sse/config/providers/registry/xai/index.ts +++ b/open-sse/config/providers/registry/xai/index.ts @@ -1,4 +1,5 @@ import type { RegistryEntry } from "../../shared.ts"; +import { resolvePublicCred } from "../../shared.ts"; export const xaiProvider: RegistryEntry = { id: "xai", @@ -38,3 +39,40 @@ export const xaiProvider: RegistryEntry = { { id: "grok-4.20-0309-non-reasoning", name: "Grok 4.20" }, ], }; + +/** + * OAuth authentication variant for the unified xAI provider. + * + * Keep the backend ID distinct because refresh and quota handling key off + * `xai-oauth`, while co-locating both variants prevents their shared endpoint + * and model catalog from drifting apart. + */ +export const xai_oauthProvider: RegistryEntry = { + id: "xai-oauth", + alias: "xao", + format: xaiProvider.format, + executor: "xai-oauth", + baseUrl: xaiProvider.baseUrl, + responsesBaseUrl: xaiProvider.responsesBaseUrl, + authType: "oauth", + authHeader: xaiProvider.authHeader, + passthroughModels: true, + oauth: { + clientIdEnv: "GROK_OAUTH_CLIENT_ID", + clientIdDefault: resolvePublicCred("grok_id", "GROK_OAUTH_CLIENT_ID"), + tokenUrl: "https://auth.x.ai/oauth2/token", + }, + models: [ + // SuperGrok / xAI OAuth serves grok-4.5 on native /v1/responses. Tag so + // chatCore translates OpenAI Chat Completions → Responses (messages→input, + // max_tokens→max_output_tokens). Without the tag, some 3.8.50 paths hit + // /v1/responses with a chat-shaped body → 422 missing `input` (#10165). + { + id: "grok-4.5", + name: "Grok 4.5", + contextLength: 500000, + targetFormat: "openai-responses", + }, + ...(xaiProvider.models || []), + ], +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index f08d62d870..4f9fb8388e 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -31,7 +31,11 @@ import { normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; -import { resolveDashboardProviderInfo, resolveProviderHeaderLink } from "../providerPageUtils"; +import { + resolveDashboardProviderInfo, + resolveProviderHeaderLink, + resolveProviderOAuthBackendId, +} from "../providerPageUtils"; import { findDefaultReferral } from "@/lib/radar/referrals"; import { type ConnectionRowConnection } from "./components/ConnectionRow"; import { useProviderConnections } from "./hooks/useProviderConnections"; @@ -254,8 +258,11 @@ export default function ProviderDetailPageClient() { providerInfo?.website, referralUrl ); + const oauthProviderId = resolveProviderOAuthBackendId(providerId, providerInfo); const providerSupportsOAuth = - providerInfo?.toggleAuthType === "oauth" || providerInfo?.toggleAuthType === "free"; + providerInfo?.toggleAuthType === "oauth" || + providerInfo?.toggleAuthType === "free" || + oauthProviderId !== providerId; const subscriptionRisk = providerInfo?.subscriptionRisk === true; // ── Phase 1t.3: connection gate + risk-notice modal state ─────────────── diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx index 92a2c40169..45aa63ec63 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx @@ -30,9 +30,11 @@ import { type BatchTestResults } from "../hooks/useProviderConnections"; import { type ConnectionDeleteConfirmState } from "../hooks/useConnectionDeleteConfirm"; import { type ImportProgress } from "../hooks/useModelImportHandlers"; import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers"; +import { resolveProviderOAuthBackendId } from "../../providerPageUtils"; interface ProviderInfo { name: string; + oauthProviderId?: string; riskNoticeVariant?: string; website?: string; [key: string]: unknown; @@ -228,6 +230,8 @@ export default function ProviderModalsPanel({ setShowTutorialModal, t, }: ProviderModalsPanelProps) { + const oauthProviderId = resolveProviderOAuthBackendId(providerId, providerInfo); + return ( <> {showRiskNoticeModal && subscriptionRisk && ( @@ -288,7 +292,7 @@ export default function ProviderModalsPanel({ setShowOAuthModal(false)} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx index dff34c534f..3dfb48e616 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/dual-auth-actions.test.tsx @@ -167,6 +167,15 @@ describe("dual-auth provider actions (#8882)", () => { expectDualAuthActions(rendered.container, rendered); }); + it("renders OAuth Connect and Manual API key for empty xAI", () => { + const rendered = renderEmptyProvider({ + providerId: "xai", + supportsDualAuth: true, + providerSupportsPat: false, + }); + expectDualAuthActions(rendered.container, rendered); + }); + it("renders OAuth Connect and Manual API key for populated CodeBuddy CN", () => { const rendered = renderPopulatedCodeBuddy(); expectDualAuthActions(rendered.container, rendered); diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 1405ce875c..185be8e945 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -8,6 +8,7 @@ import { type StaticProviderCatalogCategory, } from "@/lib/providers/catalog"; import { + getProviderConnectionFamilyIds, isClaudeCodeCompatibleProvider, supportsApiKeyOnFreeProvider, supportsDualAuthProvider, @@ -204,13 +205,8 @@ type ProviderRecord> = Record = { - alibaba: ["alibaba-cn"], - "kimi-coding": ["kimi-coding-apikey"], -}; - export function getProviderConnectionsRequestUrl(providerId: string): string { - const hasAliases = (PROVIDER_CONNECTION_ALIASES[providerId]?.length ?? 0) > 0; + const hasAliases = getProviderConnectionFamilyIds(providerId).length > 1; return hasAliases ? "/api/providers" : `/api/providers?provider=${encodeURIComponent(providerId)}`; @@ -221,8 +217,16 @@ export function connectionBelongsToProviderPage( providerId: string ): boolean { if (!connectionProvider) return false; - if (connectionProvider === providerId) return true; - return PROVIDER_CONNECTION_ALIASES[providerId]?.includes(connectionProvider) === true; + return getProviderConnectionFamilyIds(providerId).includes(connectionProvider); +} + +export function resolveProviderOAuthBackendId( + providerId: string, + provider: { oauthProviderId?: unknown } | null | undefined +): string { + return typeof provider?.oauthProviderId === "string" && provider.oauthProviderId.length > 0 + ? provider.oauthProviderId + : providerId; } /** diff --git a/src/app/api/providers/test-batch/route.ts b/src/app/api/providers/test-batch/route.ts index f8acb869d6..8daab38afd 100644 --- a/src/app/api/providers/test-batch/route.ts +++ b/src/app/api/providers/test-batch/route.ts @@ -12,6 +12,7 @@ import { AUDIO_ONLY_PROVIDERS, CLOUD_AGENT_PROVIDERS, IDE_PROVIDER_IDS, + getProviderConnectionFamilyIds, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, } from "@/shared/constants/providers"; @@ -104,7 +105,8 @@ export async function POST(request) { const idSet = new Set(connectionIds || []); connectionsToTest = allConnections.filter((c) => idSet.has(c.id)); } else if (mode === "provider" && providerId) { - connectionsToTest = allConnections.filter((c) => c.provider === providerId); + const familyProviderIds = new Set(getProviderConnectionFamilyIds(providerId)); + connectionsToTest = allConnections.filter((c) => familyProviderIds.has(c.provider)); } else if (mode === "oauth") { connectionsToTest = allConnections.filter((c) => { const authGroup = getAuthGroup(c.provider); diff --git a/src/lib/providers/catalog.ts b/src/lib/providers/catalog.ts index 47cae957d7..1f53542c6b 100644 --- a/src/lib/providers/catalog.ts +++ b/src/lib/providers/catalog.ts @@ -51,6 +51,8 @@ export interface ProviderCatalogMetadata { riskNoticeVariant?: RiskNoticeVariant; apiType?: string; baseUrl?: string; + /** Backend OAuth provider ID when one dashboard card fronts both auth modes. */ + oauthProviderId?: string; hiddenFromDashboard?: boolean; /** Optional operator-supplied remote icon URL (#2166) for compatible provider nodes. */ iconUrl?: string; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 05957989f0..526ec3ed9a 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -46,16 +46,35 @@ export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean { return typeof providerId === "string" && FREE_APIKEY_PROVIDER_IDS.has(providerId); } -// OAuth-primary providers that also accept a direct API key. Keep these out of -// FREE_APIKEY_PROVIDER_IDS so the dashboard's primary action remains OAuth. -const DUAL_AUTH_PROVIDER_IDS = new Set(["clinepass", "codebuddy-cn"]); +// Providers presented as one dashboard card with OAuth as the primary action +// and a direct API-key alternative. Keep these out of FREE_APIKEY_PROVIDER_IDS. +const DUAL_AUTH_PROVIDER_IDS = new Set(["clinepass", "codebuddy-cn", "xai"]); export function supportsDualAuthProvider(providerId: unknown): boolean { return typeof providerId === "string" && DUAL_AUTH_PROVIDER_IDS.has(providerId); } -// Web / Cookie Providers +/** + * Backend provider IDs that are managed from one dashboard provider family. + * + * Family members intentionally remain distinct in the registry and database: + * the xAI OAuth ID has different token-refresh and quota semantics from the + * API-key ID. Consumers that need to list or test every connection for a + * family should use getProviderConnectionFamilyIds() rather than duplicating + * this compatibility map. + */ +export const PROVIDER_CONNECTION_FAMILY_ALIASES: Readonly> = { + alibaba: ["alibaba-cn"], + "kimi-coding": ["kimi-coding-apikey"], + xai: ["xai-oauth", "xao"], +}; +export function getProviderConnectionFamilyIds(providerId: unknown): readonly string[] { + if (typeof providerId !== "string" || providerId.length === 0) return []; + return [providerId, ...(PROVIDER_CONNECTION_FAMILY_ALIASES[providerId] || [])]; +} + +// Web / Cookie Providers // API Key Providers diff --git a/src/shared/constants/providers/apikey/frontier-labs.ts b/src/shared/constants/providers/apikey/frontier-labs.ts index cd1f98975a..45faf24359 100644 --- a/src/shared/constants/providers/apikey/frontier-labs.ts +++ b/src/shared/constants/providers/apikey/frontier-labs.ts @@ -101,6 +101,13 @@ export const APIKEY_PROVIDERS_FRONTIER = { textIcon: "XA", website: "https://x.ai", serviceKinds: ["llm", "imageToText"], + subscriptionRisk: true, + riskNoticeVariant: "oauth", + authHint: + "Use an official xAI API key, or sign in with xAI OAuth. Grok Build JWT sessions remain a separate provider.", + // The dashboard presents xAI as one dual-auth provider while preserving + // the separate backend OAuth provider ID for token refresh and quota flow. + oauthProviderId: "xai-oauth", }, mistral: { id: "mistral", diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index c9237f0711..ec2faa8a8c 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -26,6 +26,9 @@ export const OAUTH_PROVIDERS = { website: "https://x.ai", subscriptionRisk: true, riskNoticeVariant: "oauth", + // Render xAI OAuth through the unified xAI dashboard card. Keep this + // catalog entry addressable for existing routes and stored connections. + hiddenFromDashboard: true, authHint: "Sign in with xAI to use api.x.ai models such as Grok 4.5. This is separate from Grok Build JWT sessions, which use cli-chat-proxy.grok.com and grok-build model aliases.", }, diff --git a/src/shared/validation/providerSchema.ts b/src/shared/validation/providerSchema.ts index 5fb2bc920f..929108e555 100644 --- a/src/shared/validation/providerSchema.ts +++ b/src/shared/validation/providerSchema.ts @@ -30,6 +30,7 @@ export const ProviderSchema = z.object({ freeNote: z.string().optional(), authHint: z.string().optional(), apiHint: z.string().optional(), + oauthProviderId: z.string().min(1).optional(), serviceKinds: z.array(z.enum(SERVICE_KIND_VALUES)).optional(), noAuth: z.boolean().optional(), anonymousFallback: z.boolean().optional(), diff --git a/tests/unit/provider-connections-fetch-url-2998.test.ts b/tests/unit/provider-connections-fetch-url-2998.test.ts index 3e20261216..c8c99a8c46 100644 --- a/tests/unit/provider-connections-fetch-url-2998.test.ts +++ b/tests/unit/provider-connections-fetch-url-2998.test.ts @@ -11,3 +11,7 @@ test("provider detail keeps alias-backed pages on the unfiltered request", () => assert.equal(getProviderConnectionsRequestUrl("alibaba"), "/api/providers"); assert.equal(getProviderConnectionsRequestUrl("kimi-coding"), "/api/providers"); }); + +test("unified xAI detail fetches all auth variants through the unfiltered request", () => { + assert.equal(getProviderConnectionsRequestUrl("xai"), "/api/providers"); +}); diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 03811be80f..9e2081539a 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -1102,3 +1102,47 @@ test("connectionMatchesProviderCard counts a dual-auth provider's PAT (apikey) c assert.equal(connectionMatchesProviderCard(null, "qoder", "oauth"), false); assert.equal(connectionMatchesProviderCard(undefined, "qoder", "oauth"), false); }); + +test("unified xAI OAuth card includes canonical and legacy connection provider IDs", () => { + const { + buildStaticProviderEntries, + connectionBelongsToProviderPage, + connectionMatchesProviderCard, + resolveProviderOAuthBackendId, + } = providerPageUtils; + const connections = [ + { provider: "xai", authType: "apikey" }, + { provider: "xai-oauth", authType: "oauth" }, + { provider: "xao", authType: "oauth" }, + ]; + + assert.deepEqual( + connections + .filter((connection) => connectionBelongsToProviderPage(connection.provider, "xai")) + .map((connection) => connection.provider), + ["xai", "xai-oauth", "xao"] + ); + assert.deepEqual( + connections + .filter((connection) => connectionMatchesProviderCard(connection, "xai", "oauth")) + .map((connection) => connection.provider), + ["xai", "xai-oauth", "xao"] + ); + assert.equal(resolveProviderOAuthBackendId("xai", providers.APIKEY_PROVIDERS.xai), "xai-oauth"); + assert.equal( + resolveProviderOAuthBackendId("openai", providers.APIKEY_PROVIDERS.openai), + "openai" + ); + assert.equal(providers.OAUTH_PROVIDERS["xai-oauth"].hiddenFromDashboard, true); + assert.equal(providers.supportsDualAuthProvider("xai"), true); + + const emptyStats = () => ({ total: 0 }); + assert.ok( + buildStaticProviderEntries("apikey", emptyStats).some((entry) => entry.providerId === "xai") + ); + assert.ok( + !buildStaticProviderEntries("oauth", emptyStats).some( + (entry) => entry.providerId === "xai-oauth" + ) + ); +}); diff --git a/tests/unit/xai-oauth-provider.test.ts b/tests/unit/xai-oauth-provider.test.ts index 6fdba9a1ec..6d58db0bc6 100644 --- a/tests/unit/xai-oauth-provider.test.ts +++ b/tests/unit/xai-oauth-provider.test.ts @@ -6,7 +6,7 @@ import { xaiOauth, decodeXaiIdTokenIdentity } from "../../src/lib/oauth/provider import { XAI_OAUTH_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; import { XaiExecutor } from "../../open-sse/executors/xai.ts"; -import { xai_oauthProvider } from "../../open-sse/config/providers/registry/xai-oauth/index.ts"; +import { xai_oauthProvider } from "../../open-sse/config/providers/registry/xai/index.ts"; const originalFetch = globalThis.fetch; From dd4a33d1d885da63858b7a5d2522864cc09b327f Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:14:20 +0200 Subject: [PATCH 04/36] fix(providers): make the monsterapi deprecation from #8676 actually apply (#10234) * fix(providers): make the monsterapi deprecation from #8676 actually apply #8676 marked MonsterAPI deprecated after its domain stopped resolving, but wrote the flag as `isDeprecated`. Nothing reads that key. The field the codebase consumes is `deprecated`: src/shared/validation/providerSchema.ts declares `deprecated` ProviderCard.tsx strikethrough + block icon + reason ProviderTestSlideOver.tsx warning providerOnboardingCatalog.ts Boolean(provider.deprecated), sorts last ProviderOnboardingWizard.tsx deprecated badge scripts/docs/gen-provider-reference.ts gates the DEPRECATED note Zod object schemas ignore undeclared keys, so `isDeprecated` never failed validation - it was dropped silently. The deprecation therefore had no effect anywhere, and tests/unit/8676-monsterapi-deprecation.test.ts asserted the same unread key, so it stayed green while guarding nothing. The committed docs/reference/PROVIDER_REFERENCE.md is the visible proof: the generator renders predibase (which uses `deprecated`) with a DEPRECATED note, while monsterapi still advertised "Get API key at monsterapi.ai" - a domain that does not resolve (probed 2026-08-13: api.monsterapi.ai and monsterapi.ai both 000, against api.openai.com 401 as a reachability control). Rename the key, repair the regression test to assert the consumed field and to reject the undeclared one, and refresh the generated reference row. * fix(providers): name the changelog fragment for PR #10234 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: pacocartones Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: adevwithpurpose --- .../10234-monsterapi-deprecation-inert.md | 1 + docs/reference/PROVIDER_REFERENCE.md | 2 +- .../providers/apikey/inference-hosts.ts | 2 +- .../unit/8676-monsterapi-deprecation.test.ts | 55 +++++++++++++++++-- 4 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 changelog.d/fixes/10234-monsterapi-deprecation-inert.md diff --git a/changelog.d/fixes/10234-monsterapi-deprecation-inert.md b/changelog.d/fixes/10234-monsterapi-deprecation-inert.md new file mode 100644 index 0000000000..62a95d78ab --- /dev/null +++ b/changelog.d/fixes/10234-monsterapi-deprecation-inert.md @@ -0,0 +1 @@ +- **fix(providers):** MonsterAPI's deprecation now actually applies — the flag was written as `isDeprecated`, a key no consumer or schema reads, so the provider kept rendering as healthy in the dashboard, the onboarding wizard and the generated provider reference ([#10234](https://github.com/diegosouzapw/OmniRoute/pull/10234)) diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 2cd50b7edc..b4fe4f5294 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -252,7 +252,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `mnn-ai` | `mnn-ai` | MNN AI | API key, aggregator | [link](https://mnnai.ru) | Free plan: $1 monthly credits, 10 RPM and access only to models marked Free. | | `modal` | `mdl` | Modal | API key, enterprise | [link](https://modal.com/docs) | Use the bearer token that protects your Modal deployment, if enabled. Base URL should point to your OpenAI-compatible Modal app, for example https://--.modal.run/v1. | | `modelscope` | `ms` | ModelScope | API key | [link](https://modelscope.cn) | Free tier via ModelScope API-Inference — Alibaba account required. | -| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | Get API key at monsterapi.ai | +| `monsterapi` | `monster` | MonsterAPI | API key | [link](https://monsterapi.ai) | ⚠️ **DEPRECATED.** Monster API shuttered operations on 2026-06-30. Use alternative OpenAI-compatible providers. | | `moonshot` | `moonshot` | Kimi | API key | [link](https://platform.kimi.ai?aff=omniroute) | — | | `morph` | `morph` | Morph | API key | [link](https://morphllm.com) | Free tier: 250K credits/month, $0 | | `muse-code` | `mc` | Muse Code (Meta) | API key | [link](https://github.com/meta-llama/llama-stack) | Use your META_API_KEY env var as a Bearer token. Muse Code CLI uses the OpenAI Responses API wire format (POST /responses). | diff --git a/src/shared/constants/providers/apikey/inference-hosts.ts b/src/shared/constants/providers/apikey/inference-hosts.ts index 2830e691a3..470ad9ea57 100644 --- a/src/shared/constants/providers/apikey/inference-hosts.ts +++ b/src/shared/constants/providers/apikey/inference-hosts.ts @@ -310,7 +310,7 @@ export const APIKEY_PROVIDERS_INFERENCE = { "One-time signup trial credits for decentralized GPU inference (no recurring free plan). No credit card required.", passthroughModels: true, authHint: "Get API key at monsterapi.ai", - isDeprecated: true, + deprecated: true, deprecationReason: "Monster API shuttered operations on 2026-06-30. Use alternative OpenAI-compatible providers.", }, diff --git a/tests/unit/8676-monsterapi-deprecation.test.ts b/tests/unit/8676-monsterapi-deprecation.test.ts index 6f435957c8..4bcfd1f358 100644 --- a/tests/unit/8676-monsterapi-deprecation.test.ts +++ b/tests/unit/8676-monsterapi-deprecation.test.ts @@ -2,16 +2,63 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { APIKEY_PROVIDERS_INFERENCE } from "../../src/shared/constants/providers/apikey/inference-hosts.ts"; +/** + * #8676 deprecated MonsterAPI after its domain stopped resolving, but wrote the flag + * as `isDeprecated` — a key no schema declares and no consumer reads. The catalog + * field the codebase actually consumes is `deprecated`: + * + * src/shared/validation/providerSchema.ts declares `deprecated`, not `isDeprecated` + * ProviderCard.tsx `provider.deprecated` (strikethrough + block icon) + * ProviderTestSlideOver.tsx `provider.deprecated` (warning) + * providerOnboardingCatalog.ts `Boolean(provider.deprecated)` + sorts last + * ProviderOnboardingWizard.tsx `option.deprecated` (badge) + * scripts/docs/gen-provider-reference.ts `p.deprecated` gates the DEPRECATED note + * + * Because Zod object schemas ignore undeclared keys, `isDeprecated` never failed + * validation — it was silently dropped, so the deprecation had no effect anywhere + * while this test stayed green. + * + * Upstream state re-probed 2026-08-13, with paired controls: + * GET https://api.monsterapi.ai/v1/chat/completions -> 000 (does not resolve) + * GET https://monsterapi.ai -> 000 (does not resolve) + * GET https://api.openai.com/v1/models -> 401 (control: reachable) + * GET https:// -> 000 (control: unreachable) + */ test("Monster API provider is marked as deprecated (fixes #8676)", () => { - const monsterEntry = APIKEY_PROVIDERS_INFERENCE.monsterapi; + const monsterEntry = APIKEY_PROVIDERS_INFERENCE.monsterapi as Record; assert.ok(monsterEntry, "monsterapi entry must exist in APIKEY_PROVIDERS_INFERENCE"); assert.equal( - (monsterEntry as Record).isDeprecated, + monsterEntry.deprecated, true, - "monsterapi must be marked isDeprecated" + "monsterapi must set `deprecated` — the field every consumer and the Zod schema read" ); assert.ok( - typeof (monsterEntry as Record).deprecationReason === "string", + typeof monsterEntry.deprecationReason === "string", "monsterapi must specify deprecationReason" ); }); + +test("Monster API deprecation uses no undeclared flag name (#8676)", () => { + const monsterEntry = APIKEY_PROVIDERS_INFERENCE.monsterapi as Record; + assert.equal( + "isDeprecated" in monsterEntry, + false, + "`isDeprecated` is read by nothing and silently dropped by the provider schema — " + + "the consumed field is `deprecated`" + ); +}); + +test("Monster API deprecation matches the flag shape of its sibling entries (#8676)", () => { + // predibase, in this same catalog, is the reference implementation: its `deprecated` + // flag is what makes the generated PROVIDER_REFERENCE.md render its DEPRECATED note. + const monsterEntry = APIKEY_PROVIDERS_INFERENCE.monsterapi as Record; + const predibaseEntry = APIKEY_PROVIDERS_INFERENCE.predibase as Record; + assert.equal(predibaseEntry.deprecated, true, "predibase is the in-file reference for the flag"); + for (const field of ["deprecated", "deprecationReason"]) { + assert.equal( + typeof monsterEntry[field], + typeof predibaseEntry[field], + `monsterapi must declare ${field} the same way predibase does` + ); + } +}); From 462f4fc9da63f61e3d3cac1ca11d70fd8ddfc054 Mon Sep 17 00:00:00 2001 From: Aman <1402357+Zartharas@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:14:26 -0600 Subject: [PATCH 05/36] fix(providers): preserve connection test status codes (#10272) --- ...72-provider-test-statuscode-propagation.md | 1 + .../providers/[id]/test/apiKeyTestResult.ts | 28 ++++++++ src/app/api/providers/[id]/test/route.ts | 14 ++-- ...ovider-test-statuscode-propagation.test.ts | 68 +++++++++++++++++++ 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 changelog.d/fixes/10272-provider-test-statuscode-propagation.md create mode 100644 src/app/api/providers/[id]/test/apiKeyTestResult.ts create mode 100644 tests/unit/provider-test-statuscode-propagation.test.ts diff --git a/changelog.d/fixes/10272-provider-test-statuscode-propagation.md b/changelog.d/fixes/10272-provider-test-statuscode-propagation.md new file mode 100644 index 0000000000..5102bf9c45 --- /dev/null +++ b/changelog.d/fixes/10272-provider-test-statuscode-propagation.md @@ -0,0 +1 @@ +- **fix(providers):** preserve validator HTTP status codes in API-key and web connection-test results so callers can distinguish authentication, rate-limit, and upstream failures ([#10272](https://github.com/diegosouzapw/OmniRoute/pull/10272)) — thanks @Zartharas diff --git a/src/app/api/providers/[id]/test/apiKeyTestResult.ts b/src/app/api/providers/[id]/test/apiKeyTestResult.ts new file mode 100644 index 0000000000..80e8788f7d --- /dev/null +++ b/src/app/api/providers/[id]/test/apiKeyTestResult.ts @@ -0,0 +1,28 @@ +export interface ApiKeyValidationResult { + valid: boolean; + warning?: string | null; + statusCode?: number | null; + deployments?: unknown; +} + +export interface ApiKeyTestDiagnosis { + type: string; + source: string; + message: string | null; + code: string | null; +} + +export function buildApiKeyConnectionTestResult( + result: ApiKeyValidationResult, + error: string | null, + diagnosis: ApiKeyTestDiagnosis +) { + return { + valid: !!result.valid, + error, + warning: result.warning || null, + statusCode: result.valid ? null : (result.statusCode ?? null), + diagnosis, + ...(Array.isArray(result.deployments) ? { deployments: result.deployments } : {}), + }; +} diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 4d440e6936..1201826c83 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -23,6 +23,7 @@ import { isGitLabDirectAccessDisabled } from "@/lib/oauth/gitlab"; import { providerAllowsOptionalApiKey } from "@/shared/constants/providers"; import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts"; import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; +import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult"; import { OAUTH_TEST_CONFIG } from "./oauthTestConfig"; // Bound the OAuth probe so a hung upstream can't block the connection-test queue @@ -614,15 +615,7 @@ async function testApiKeyConnection(connection: any) { ? makeDiagnosis("ok", "upstream", null, null) : classifyFailure({ error, statusCode: result.statusCode, provider: connection.provider }); - return { - valid: !!result.valid, - error, - warning: result.warning || null, - diagnosis, - ...(Array.isArray((result as any).deployments) - ? { deployments: (result as any).deployments } - : {}), - }; + return buildApiKeyConnectionTestResult(result, error, diagnosis); } /** @@ -709,7 +702,8 @@ export async function testSingleConnection(connectionId: string, validationModel // failures a short cooldown so the lazy-recovery path retries them. const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]); const isTerminalFailure = - !result.valid && terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase()); + !result.valid && + terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase()); const testFailureCooldownMs = result.valid ? 0 : 30_000; // 30s retry window const updateData: Record = { diff --git a/tests/unit/provider-test-statuscode-propagation.test.ts b/tests/unit/provider-test-statuscode-propagation.test.ts new file mode 100644 index 0000000000..74831a5589 --- /dev/null +++ b/tests/unit/provider-test-statuscode-propagation.test.ts @@ -0,0 +1,68 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildApiKeyConnectionTestResult } = + await import("../../src/app/api/providers/[id]/test/apiKeyTestResult.ts"); + +const FAILURE_DIAGNOSIS = { + type: "synthetic_failure", + source: "test", + message: "Synthetic validator failure", + code: "synthetic", +}; + +test("API-key connection tests preserve validator failure status codes", () => { + for (const statusCode of [401, 403, 429, 503]) { + const result = buildApiKeyConnectionTestResult( + { + valid: false, + warning: null, + statusCode, + }, + `Synthetic validator failure ${statusCode}`, + FAILURE_DIAGNOSIS + ); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, statusCode); + assert.deepEqual(result.diagnosis, FAILURE_DIAGNOSIS); + } +}); + +test("status-less semantic failures remain status-less", () => { + const result = buildApiKeyConnectionTestResult( + { + valid: false, + warning: null, + }, + "Synthetic semantic failure", + FAILURE_DIAGNOSIS + ); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, null); + assert.deepEqual(result.diagnosis, FAILURE_DIAGNOSIS); +}); + +test("successful validation does not synthesize an HTTP status", () => { + const diagnosis = { + type: "ok", + source: "upstream", + message: null, + code: null, + }; + + const result = buildApiKeyConnectionTestResult( + { + valid: true, + warning: null, + statusCode: 200, + }, + null, + diagnosis + ); + + assert.equal(result.valid, true); + assert.equal(result.statusCode, null); + assert.deepEqual(result.diagnosis, diagnosis); +}); From b67d9ef353c10b6b4868cbddefe75495e5ccca86 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:14:30 +0200 Subject: [PATCH 06/36] fix(db): publish the sql.js database atomically instead of rewriting it in place (#10278) sql.js has no incremental write path, so persist() rewrites the whole image on every save. Going through fs.writeFileSync(filePath, ...) opened the destination with O_TRUNC, leaving the on-disk database 0 bytes and then partial for the whole write -- a window that scales with database size and recurs on every save. Unlike better-sqlite3 / node:sqlite, that window is not covered by SQLite's locking protocol, so it is visible to every other process reading the same file: a backup job, a metrics exporter, an operator running sqlite3. Those readers get SQLITE_CORRUPT ("database disk image is malformed") while PRAGMA integrity_check passes moments later, which makes the failure look random and blames the reader. Now: temp file in the same directory, fsync, rename() over the destination. rename is atomic on POSIX and on Windows for a same-volume replace, so a reader sees either the previous image or the new one, never a truncated one. It also closes a total-loss window: a crash mid-write used to leave the real database truncated, and now only leaves a stale temp file behind. The regression guard asserts the property that separates the two implementations without racing a timer: a reader that opened the file before a save still reads a complete, valid image afterwards, and the published file sits on a new inode. It fails on the previous implementation and passes on this one. Co-authored-by: Max Co-authored-by: adevwithpurpose --- changelog.d/fixes/sqljs-atomic-persist.md | 1 + src/lib/db/adapters/sqljsAdapter.ts | 54 +++++++++- tests/unit/db-sqljs-atomic-persist.test.ts | 116 +++++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/sqljs-atomic-persist.md create mode 100644 tests/unit/db-sqljs-atomic-persist.test.ts diff --git a/changelog.d/fixes/sqljs-atomic-persist.md b/changelog.d/fixes/sqljs-atomic-persist.md new file mode 100644 index 0000000000..db50495f4d --- /dev/null +++ b/changelog.d/fixes/sqljs-atomic-persist.md @@ -0,0 +1 @@ +- **fix(db):** the sql.js fallback now publishes the database atomically — temp file in the same directory, `fsync`, then `rename()` — instead of rewriting it in place with `writeFileSync`. sql.js has no incremental write path, so every save rewrote the whole image through an `O_TRUNC` open: for the duration of the write the on-disk database was 0 bytes and then partial, a window that scales with database size and recurs on every save. Unlike better-sqlite3 / node:sqlite, that window is not covered by SQLite's locking protocol, so it was visible to every OTHER process reading the same file (a backup job, a metrics exporter, an operator running `sqlite3`), which got `SQLITE_CORRUPT` — "database disk image is malformed" — while `PRAGMA integrity_check` passed moments later. It also closes a total-loss window: a crash mid-write used to leave the real database truncated, and now only leaves a stale temp file diff --git a/src/lib/db/adapters/sqljsAdapter.ts b/src/lib/db/adapters/sqljsAdapter.ts index 42abd2158a..886c52672b 100644 --- a/src/lib/db/adapters/sqljsAdapter.ts +++ b/src/lib/db/adapters/sqljsAdapter.ts @@ -127,10 +127,62 @@ export async function createSqlJsAdapter(filePath: string): Promise | null = null; let _isOpen = true; + /** + * Writes the whole database image out atomically: temp file in the SAME + * directory, fsync, then `rename()` over the destination. + * + * WHY NOT `writeFileSync(filePath, …)` DIRECTLY + * --------------------------------------------- + * sql.js has no incremental write path — every save rewrites the entire image. + * `writeFileSync` opens the destination with `O_TRUNC`, so for the whole + * duration of the write the on-disk database is 0 bytes and then partial. The + * window scales with the database size and recurs on every save, so on a busy + * instance it is open a significant fraction of the time. + * + * Unlike better-sqlite3 / node:sqlite, that window is not protected by SQLite's + * locking protocol, so it is visible to every OTHER process that reads the same + * file — a backup job, a metrics exporter, an operator running `sqlite3`. Those + * readers get `SQLITE_CORRUPT` ("database disk image is malformed") even though + * `PRAGMA integrity_check` passes moments later, which makes the failure look + * random and points the blame at the reader. + * + * `rename()` within a directory is atomic on POSIX and on Windows for a + * same-volume replace, so a reader now sees either the previous image or the + * new one — never a truncated one. It also removes the total-loss window: a + * crash mid-write used to leave the real database truncated, while it now only + * leaves a stale temp file behind. + */ function persist(): void { if (filePath === ":memory:") return; const data = db.export(); - fs.writeFileSync(filePath, Buffer.from(data)); + // Same directory, so `rename` stays within one filesystem — a temp file in + // os.tmpdir() would make it a cross-device copy, which is not atomic. + const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`; + let fd: number | null = null; + try { + fd = fs.openSync(tmpPath, "w"); + fs.writeFileSync(fd, Buffer.from(data)); + // The rename is atomic, but only orders against data that already reached + // the disk; without this an unclean shutdown can publish an empty file. + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = null; + fs.renameSync(tmpPath, filePath); + } catch (err) { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + /* already closed */ + } + } + try { + fs.unlinkSync(tmpPath); + } catch { + /* never created, or already gone */ + } + throw err; + } dirty = false; } diff --git a/tests/unit/db-sqljs-atomic-persist.test.ts b/tests/unit/db-sqljs-atomic-persist.test.ts new file mode 100644 index 0000000000..bb61f2a5c3 --- /dev/null +++ b/tests/unit/db-sqljs-atomic-persist.test.ts @@ -0,0 +1,116 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Regression guard: sql.js has no incremental write path, so every save rewrites +// the whole database image. When that write went through +// `fs.writeFileSync(filePath, …)`, the destination was opened with `O_TRUNC` — +// for the whole duration of the write the on-disk database was 0 bytes and then +// partial. Unlike better-sqlite3 / node:sqlite, that window is NOT covered by +// SQLite's locking protocol, so it was visible to every other process reading the +// same file (backup job, metrics exporter, an operator running `sqlite3`). Those +// readers got SQLITE_CORRUPT — "database disk image is malformed" — while +// `PRAGMA integrity_check` passed moments later, which made the failure look +// random and blamed the reader. The window scales with database size and recurs +// on every save. +// +// The fix writes to a temp file in the same directory and `rename()`s it over the +// destination. The property that distinguishes the two implementations, and the +// one asserted below, is inode identity: `rename` publishes a NEW inode, so a +// reader that already opened the file keeps reading a complete, coherent image, +// whereas `writeFileSync` mutates the inode the reader is holding. +// +// This is deliberately not a timing race — a sleep-based test would be flaky and +// would not prove anything about small databases that get written in one go. + +async function openAdapter(sqliteFile: string) { + const { createSqlJsAdapter } = await import("../../src/lib/db/adapters/sqljsAdapter"); + return createSqlJsAdapter(sqliteFile); +} + +test( + "sql.js persist() publishes the database atomically — a reader holding the file " + + "open never observes a truncated image (rename, not in-place O_TRUNC)", + async () => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sqljs-atomic-")); + const sqliteFile = path.join(dataDir, "storage.sqlite"); + let adapter: Awaited> | null = null; + let readerFd: number | null = null; + try { + adapter = await openAdapter(sqliteFile); + adapter.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)"); + adapter.exec("INSERT INTO t (v) VALUES ('first')"); + adapter.checkpoint(); + + assert.ok(fs.existsSync(sqliteFile), "first checkpoint should have written the database"); + const firstBytes = fs.readFileSync(sqliteFile); + const firstInode = fs.statSync(sqliteFile).ino; + + // A concurrent reader that opened the file before the next save. It keeps + // reading through THIS descriptor, exactly like another process mid-read. + readerFd = fs.openSync(sqliteFile, "r"); + + // Grow the image so the second save is unmistakably a different payload. + for (let i = 0; i < 200; i++) { + adapter.exec(`INSERT INTO t (v) VALUES ('row-${i}')`); + } + adapter.checkpoint(); + + // 1. The reader's descriptor still resolves to a COMPLETE image. Under + // writeFileSync it resolves to the same inode that was truncated and + // rewritten, so this read returns the new (or a torn) payload. + const viaReader = Buffer.alloc(firstBytes.length); + const read = fs.readSync(readerFd, viaReader, 0, firstBytes.length, 0); + assert.equal(read, firstBytes.length, "the pre-opened descriptor lost bytes mid-write"); + assert.deepEqual( + viaReader, + firstBytes, + "a reader holding the file open observed the image change underneath it — " + + "persist() replaced the file in place instead of renaming a new one over it" + ); + assert.equal( + viaReader.subarray(0, 15).toString("latin1"), + "SQLite format 3", + "the pre-opened descriptor no longer sees a valid SQLite header" + ); + + // 2. The published file is the NEW image, on a NEW inode — that is what + // makes the swap atomic for everyone who opens it afterwards. + const secondInode = fs.statSync(sqliteFile).ino; + assert.notEqual( + secondInode, + firstInode, + "persist() reused the same inode — the write was not published by rename()" + ); + assert.equal( + fs.readFileSync(sqliteFile).subarray(0, 15).toString("latin1"), + "SQLite format 3", + "the published file is not a valid SQLite image" + ); + + // 3. No temp file survives a successful save. + const leftovers = fs.readdirSync(dataDir).filter((n) => n.startsWith("storage.sqlite.tmp-")); + assert.deepEqual(leftovers, [], "persist() left a temporary file behind"); + } finally { + if (readerFd !== null) fs.closeSync(readerFd); + if (adapter?.open) adapter.close(); + fs.rmSync(dataDir, { recursive: true, force: true }); + } + } +); + +test("sql.js persist() is a no-op for :memory: databases (no temp file, no throw)", async () => { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-sqljs-atomic-mem-")); + let adapter: Awaited> | null = null; + try { + adapter = await openAdapter(":memory:"); + adapter.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)"); + adapter.checkpoint(); + assert.deepEqual(fs.readdirSync(dataDir), [], "an in-memory database wrote to disk"); + } finally { + if (adapter?.open) adapter.close(); + fs.rmSync(dataDir, { recursive: true, force: true }); + } +}); From 710e43eb97a7483c75a8cfa257ed3e564d855f0f Mon Sep 17 00:00:00 2001 From: Harkaran Brar Date: Sat, 15 Aug 2026 20:14:35 -0700 Subject: [PATCH 07/36] fix(sse): answer tiny-budget reasoning probes with a truncated 200 (#10281) (#10284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sse): answer tiny-budget reasoning probes with a truncated 200 (#10281) Claude Code's /model capability check sends max_tokens: 1. Reasoning models burn the whole probe on thinking, and some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the empty outcome with a 5xx "empty response content" instead of a truncated 200. The relayed failure also marked the connection unavailable and poisoned fallback/cooldown bookkeeping for what is only a probe. Detect tiny-budget reasoning probes in the non-streaming providerFailure path and synthesize a valid truncated response (200, empty content, finish_reason "length") — the same semantics errorClassifier.ts already grants to length-truncated empty 200s. Probes no longer poison connection health. Refs #10281. * chore(changelog): add fragment for reasoning-probe truncated-200 fix (#10284) --- .../10284-reasoning-probe-truncated-200.md | 1 + open-sse/handlers/chatCore.ts | 70 ++++++- open-sse/services/reasoningTokenBuffer.ts | 71 ++++++++ ...ing-probe-truncated-response-10281.test.ts | 171 ++++++++++++++++++ 4 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 changelog.d/fixes/10284-reasoning-probe-truncated-200.md create mode 100644 tests/unit/reasoning-probe-truncated-response-10281.test.ts diff --git a/changelog.d/fixes/10284-reasoning-probe-truncated-200.md b/changelog.d/fixes/10284-reasoning-probe-truncated-200.md new file mode 100644 index 0000000000..c3ddd311d2 --- /dev/null +++ b/changelog.d/fixes/10284-reasoning-probe-truncated-200.md @@ -0,0 +1 @@ +- **fix(sse):** tiny-budget reasoning probes (e.g. Claude Code's `/model` check sends `max_tokens: 1`) are answered with a valid truncated 200 instead of relaying the upstream 5xx "empty response content" — which previously also marked the connection unavailable and poisoned fallback/cooldown bookkeeping for a request that is only a probe ([#10281](https://github.com/diegosouzapw/OmniRoute/issues/10281)) — thanks @harkaranbrar7 diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8fbb425eda..ce0656323d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -159,7 +159,13 @@ import { buildCapabilityMismatchMessage, } from "@/shared/constants/capabilities/capabilityFilter.ts"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts"; -import { toPositiveInteger } from "../services/reasoningTokenBuffer.ts"; +import { + REASONING_BUFFER_MIN_TRIGGER, + buildReasoningProbeTruncatedResponse, + isEmptyContentUpstreamFailure, + isTinyBudgetReasoningProbe, + toPositiveInteger, +} from "../services/reasoningTokenBuffer.ts"; import { normalizeThinkingForModel } from "@/shared/constants/modelSpecs.ts"; import { buildErrorBody, @@ -248,7 +254,10 @@ import { normalizeOpenAIToolFinishReasons, restoreNonStreamingToolNames, } from "./chatCore/passthroughToolNames.ts"; -import { createDisabledCompressionConfig, resolveCompressionSettings } from "./chatCore/compressionSettings.ts"; +import { + createDisabledCompressionConfig, + resolveCompressionSettings, +} from "./chatCore/compressionSettings.ts"; import type { EnforceDecision } from "@/lib/quota/types"; import { isCompressionExcluded } from "../services/compression/exclusions.ts"; import { @@ -1823,7 +1832,11 @@ export async function handleChatCore({ // engines (Caveman/RTK). Codex Desktop / Responses clients need this path even // when those engines are off, otherwise multi-turn image sessions hard-reject // at the budget check below (#8560). - if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && estimatedTokens > threshold) { + if ( + reactiveContextCompactionEnabled && + !nativeCodexPassthrough && + estimatedTokens > threshold + ) { log?.info?.( "CONTEXT", `Proactive compression triggered: ${estimatedTokens} tokens > ${threshold} threshold (${contextLimit} limit)` @@ -1893,7 +1906,12 @@ export async function handleChatCore({ // Last-resort compaction against the concrete input budget (not the 70% threshold). // Covers cases where the proactive pass was skipped or still left the request oversized (#8560). - if (reactiveContextCompactionEnabled && !nativeCodexPassthrough && finalEstimatedInputTokens >= finalContextLimit && body) { + if ( + reactiveContextCompactionEnabled && + !nativeCodexPassthrough && + finalEstimatedInputTokens >= finalContextLimit && + body + ) { const lastResortTarget = Math.max(1, finalContextLimit - toolsReserve - 1); const lastResortAdapter = adaptBodyForCompression(body as Record); const lastResortResult = compressContext(lastResortAdapter.body, { @@ -3734,6 +3752,33 @@ export async function handleChatCore({ if (signatureRecovery.succeeded) break providerFailure; + // #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` check + // sends `max_tokens: 1`): the model burns the whole budget on thinking, and + // some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the empty + // outcome with a 5xx ("empty response content") instead of a truncated 200. + // Answer such probes with a valid truncated response rather than relaying the + // upstream failure — which would also mark the connection unavailable and + // poison fallback/cooldown bookkeeping for a request that is only a probe. + if ( + !stream && + isTinyBudgetReasoningProbe({ model: currentModel, body: finalBody || translatedBody }) && + isEmptyContentUpstreamFailure(statusCode, message) + ) { + providerResponse = buildReasoningProbeTruncatedResponse({ + model: currentModel, + maxTokens: toPositiveInteger( + (finalBody || translatedBody)?.max_tokens ?? + (finalBody || translatedBody)?.max_completion_tokens + ), + requestId: skillRequestId, + }); + log?.warn?.( + "PROBE", + `Reasoning probe (max_tokens < ${REASONING_BUFFER_MIN_TRIGGER}) answered with truncated 200 — upstream reported "${message}"` + ); + break providerFailure; + } + // T06/T10/T36: classify provider errors and persist terminal account states. let errorType = classifyProviderError(statusCode, message, provider); if (statusCode === 429 && isModelScope()) { @@ -4355,7 +4400,11 @@ export async function handleChatCore({ } : responseBody ); - sanitizeUsagePayloadForRequest(responseBody, finalBody || translatedBody || body, responsePayloadFormat); + sanitizeUsagePayloadForRequest( + responseBody, + finalBody || translatedBody || body, + responsePayloadFormat + ); effectiveServiceTier = resolveReportedServiceTier(responseBody) ?? effectiveServiceTier; // Notify success - caller can clear error status if needed if (onRequestSuccess) { @@ -4500,9 +4549,14 @@ export async function handleChatCore({ // #8331: keep the client-visible metering fields real everywhere except Claude-Code-compatible // providers, where Claude Code's own context accounting relies on the buffered number — see // clientUsageBuffer.ts module docstring. - applyClientUsageBuffer(translatedResponse, finalBody || translatedBody || body, clientResponseFormat, { - preserveContextBudgetInVisibleUsage: isClaudeCodeCompatible, - }); + applyClientUsageBuffer( + translatedResponse, + finalBody || translatedBody || body, + clientResponseFormat, + { + preserveContextBudgetInVisibleUsage: isClaudeCodeCompatible, + } + ); if (memoryOwnerId && memorySettings?.enabled && memorySettings.maxTokens > 0) { const requestMemoryText = extractMemoryTextFromRequestBody(body as Record); diff --git a/open-sse/services/reasoningTokenBuffer.ts b/open-sse/services/reasoningTokenBuffer.ts index 4c5ce88059..fc4ac7157f 100644 --- a/open-sse/services/reasoningTokenBuffer.ts +++ b/open-sse/services/reasoningTokenBuffer.ts @@ -54,3 +54,74 @@ export function resolveReasoningBufferedMaxTokens( // silent cost increase the client did not authorize. return current; } + +/** + * A tiny-budget reasoning probe is a request with an explicit `max_tokens` + * below REASONING_BUFFER_MIN_TRIGGER targeting a reasoning-capable model — e.g. + * Claude Code's `/model` capability check sends `max_tokens: 1`. Reasoning + * models burn the whole probe on thinking, so the upstream produces no visible + * content; some upstreams (e.g. api.cline.bot for deepseek-v4-flash) answer the + * non-streaming probe with an HTTP 5xx (`"empty response content"`) instead of + * a truncated 200. See #10281. + */ +export function isTinyBudgetReasoningProbe(opts: { model: string; body: unknown }): boolean { + const body = (opts.body ?? {}) as Record; + const maxTokens = toPositiveInteger(body.max_tokens ?? body.max_completion_tokens); + if (maxTokens === null || maxTokens >= REASONING_BUFFER_MIN_TRIGGER) return false; + const capabilities = getResolvedModelCapabilities(opts.model); + return capabilities.supportsThinking === true; +} + +/** + * Upstream failure markers that describe the "model reasoned but produced no + * visible content" outcome (e.g. `{"error":{"message":"empty response content"}}`). + */ +const EMPTY_CONTENT_FAILURE_RE = + /empty(\s+response)?\s+content|no\s+(usable\s+)?content|reasoning\s+consumed/i; + +/** + * True when the upstream failure is a 5xx describing the empty-content outcome + * of a reasoning probe rather than a genuine provider outage. Combined with + * `isTinyBudgetReasoningProbe`, false positives are not practical (a real 5xx + * carrying these markers on a tiny-budget reasoning request is this exact case). + */ +export function isEmptyContentUpstreamFailure(statusCode: number, message: string): boolean { + if (!Number.isFinite(statusCode) || statusCode < 500 || statusCode >= 600) return false; + return EMPTY_CONTENT_FAILURE_RE.test(String(message || "")); +} + +/** + * Build a valid truncated OpenAI chat.completion response (200, empty content, + * `finish_reason: "length"`) used to answer a tiny-budget reasoning probe whose + * upstream answered the empty outcome with a 5xx. Mirrors the semantics OmniRoute + * already grants to `finish_reason: "length"` empty 200s (errorClassifier.ts). + */ +export function buildReasoningProbeTruncatedResponse(opts: { + model: string; + maxTokens: number | null; + requestId: string; +}): Response { + const maxTokens = opts.maxTokens ?? 1; + const body = { + id: `chatcmpl-${opts.requestId}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: opts.model, + choices: [ + { + index: 0, + message: { role: "assistant", content: "" }, + finish_reason: "length", + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: maxTokens, + total_tokens: maxTokens, + }, + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} diff --git a/tests/unit/reasoning-probe-truncated-response-10281.test.ts b/tests/unit/reasoning-probe-truncated-response-10281.test.ts new file mode 100644 index 0000000000..b69a452def --- /dev/null +++ b/tests/unit/reasoning-probe-truncated-response-10281.test.ts @@ -0,0 +1,171 @@ +/** + * #10281 — tiny-budget reasoning probes (e.g. Claude Code's `/model` capability + * check sends `max_tokens: 1`) must be answered with a valid truncated 200 when + * the upstream answers the reasoning-only outcome with a 5xx ("empty response + * content") instead of a truncated 200 — rather than relaying the upstream + * failure, which also poisons connection cooldown/health bookkeeping. + * + * Covers the pure helpers in open-sse/services/reasoningTokenBuffer.ts: + * - isTinyBudgetReasoningProbe — probe detection + * - isEmptyContentUpstreamFailure — empty-content 5xx detection + * - buildReasoningProbeTruncatedResponse — synthetic truncated 200 + * plus the invariant that the synthetic body is NOT flagged as empty content by + * errorClassifier.isEmptyContentResponse (finish_reason "length" is legitimate). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-reasoning-probe-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { saveModelsDevCapabilities, clearModelsDevCapabilities } = + await import("../../src/lib/modelsDevSync.ts"); +const { + REASONING_BUFFER_MIN_TRIGGER, + buildReasoningProbeTruncatedResponse, + isEmptyContentUpstreamFailure, + isTinyBudgetReasoningProbe, +} = await import("../../open-sse/services/reasoningTokenBuffer.ts"); +const { isEmptyContentResponse } = await import("../../open-sse/services/errorClassifier.ts"); + +function capabilityEntry(limitContext: unknown, overrides: Record = {}) { + return { + tool_call: true, + reasoning: false, + attachment: false, + structured_output: true, + temperature: true, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: false, + limit_context: limitContext, + limit_input: limitContext, + limit_output: 4096, + interleaved_field: null, + ...overrides, + }; +} + +test.before(() => { + saveModelsDevCapabilities({ + zhipu: { + // A thinking-capable model: probe detection + buffer logic both engage. + "glm-5.2": capabilityEntry(200000, { reasoning: true, limit_output: 65536 }), + // A non-reasoning sibling: probes are not special-cased. + "glm-5.2-flash": capabilityEntry(200000, { reasoning: false, limit_output: 4096 }), + }, + }); +}); + +test.after(() => { + clearModelsDevCapabilities(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#10281 isTinyBudgetReasoningProbe detects tiny explicit budgets on reasoning models", () => { + const thinking = "zhipu/glm-5.2"; + // Claude Code's `/model` probe (max_tokens: 1) is a tiny-budget reasoning probe. + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: { max_tokens: 1 } }), + true, + "max_tokens=1 on a reasoning model is a probe" + ); + // Just below the trigger threshold is still a probe. + assert.equal( + isTinyBudgetReasoningProbe({ + model: thinking, + body: { max_tokens: REASONING_BUFFER_MIN_TRIGGER - 1 }, + }), + true, + "budgets below REASONING_BUFFER_MIN_TRIGGER are probes" + ); + // At/above the threshold it is a genuine budget, not a probe. + assert.equal( + isTinyBudgetReasoningProbe({ + model: thinking, + body: { max_tokens: REASONING_BUFFER_MIN_TRIGGER }, + }), + false, + "budgets at REASONING_BUFFER_MIN_TRIGGER are not probes" + ); + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: { max_tokens: 512 } }), + false, + "genuine budgets are not probes" + ); + // OpenAI Responses format field is honoured. + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: { max_completion_tokens: 1 } }), + true, + "max_completion_tokens=1 is a probe" + ); + // Missing / non-positive budgets are not probes. + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: {} }), + false, + "no budget is not a probe" + ); + assert.equal( + isTinyBudgetReasoningProbe({ model: thinking, body: { max_tokens: 0 } }), + false, + "non-positive budget is not a probe" + ); + // Non-reasoning models never probe-special-case. + assert.equal( + isTinyBudgetReasoningProbe({ model: "zhipu/glm-5.2-flash", body: { max_tokens: 1 } }), + false, + "non-reasoning models are not probes" + ); +}); + +test("#10281 isEmptyContentUpstreamFailure matches empty-content 5xx markers", () => { + assert.equal(isEmptyContentUpstreamFailure(500, "empty response content"), true); + assert.equal(isEmptyContentUpstreamFailure(500, "No content was produced"), true); + assert.equal(isEmptyContentUpstreamFailure(502, "empty response content"), true); + assert.equal( + isEmptyContentUpstreamFailure(500, "empty response body"), + false, + "generic empty-body 5xx is not a reasoning outcome" + ); + assert.equal(isEmptyContentUpstreamFailure(500, "server_error"), false); + assert.equal(isEmptyContentUpstreamFailure(503, "upstream timeout"), false); + assert.equal( + isEmptyContentUpstreamFailure(429, "empty response content"), + false, + "non-5xx is not an empty-content failure" + ); + assert.equal(isEmptyContentUpstreamFailure(200, "empty response content"), false); +}); + +test("#10281 buildReasoningProbeTruncatedResponse yields a valid truncated 200", async () => { + const res = buildReasoningProbeTruncatedResponse({ + model: "zhipu/glm-5.2", + maxTokens: 1, + requestId: "test-request-id", + }); + assert.equal(res.status, 200); + assert.match(res.headers.get("content-type") || "", /application\/json/); + + const body = (await res.json()) as Record; + const choice = (body.choices as Array>)[0]; + assert.equal(body.object, "chat.completion"); + assert.equal(body.model, "zhipu/glm-5.2"); + assert.equal(choice.finish_reason, "length"); + assert.equal((choice.message as Record).content, ""); + assert.equal((body.usage as Record).completion_tokens, 1); + + // The synthetic body must pass the empty-content guard (finish_reason "length" + // is a legitimate truncated completion — see errorClassifier.ts) so the + // non-stream success path does not re-flag it as a fake-success failure. + assert.equal(isEmptyContentResponse(body), false, "truncated probe response is a legitimate 200"); +}); From cb51facf12515c9929d9f47271881d1c4c1e6968 Mon Sep 17 00:00:00 2001 From: Anudeep Adiraju <63069338+anudeepadi@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:14:40 -0500 Subject: [PATCH 08/36] fix(docker): prefix cache mount ids with Railway service scope (#10288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(docker): prefix cache mount ids with Railway service scope Railway's Dockerfile builder rejects --mount=type=cache ids that lack the s/- prefix (dockerfile invalid, caught at syntax validation before any build step runs). Prefix all 7 cache mount ids (apt-cache, apt-lists x4 RUN blocks, npm-cache x2, next-cache x1) with the omni-route service id. * fix(sse): remove duplicate sseCommentsEnabled import in stream.ts Turbopack rejected the file with 'the name sseCommentsEnabled is defined multiple times' — imported once at the top of the file and again lower down from the same module. Broke every production build (Docker/Railway) at the release/v3.8.50 tip, independent of the cache mount fix in this branch. Validated by a full Docker build on Railway completing past this step. --- Dockerfile | 22 +++++++++++----------- open-sse/utils/stream.ts | 7 ++++--- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index ddcb04975b..eefd6ed57d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,8 @@ WORKDIR /app # that already have a fix published in trixie. CVEs without an upstream fix yet # (local-only TOCTOU, etc.) remain until the distro patches them and the image # is rebuilt; none are reachable from the proxy's request surface at runtime. -RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ +RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \ apt-get update \ && apt-get upgrade -y \ && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ @@ -61,8 +61,8 @@ FROM base AS builder # Build tools for native module compilation # apt-get update needed here because base's rm -rf clears the shared cache -RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ +RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \ apt-get update \ && apt-get install -y --no-install-recommends python3 make g++ \ && rm -rf /var/lib/apt/lists/* @@ -108,7 +108,7 @@ RUN test -f package-lock.json \ # in production (TlsClientUnavailableError, #7802). Run it explicitly here so # a broken/rate-limited fetch fails the BUILD loudly instead of shipping a # broken image. -RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ +RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \ npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \ && (cd node_modules/better-sqlite3 \ && node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \ @@ -158,7 +158,7 @@ ARG OMNIROUTE_BUILD_MEMORY_MB=4096 ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}" COPY . ./ -RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \ +RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \ mkdir -p /app/data \ && npm run build \ && node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);" @@ -262,8 +262,8 @@ COPY --from=builder /app/node_modules/playwright ./node_modules/playwright # browsers land under /home/node which persists across image layers and is # accessible to the non-root runtime user. ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright -RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ +RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \ apt-get update \ && node node_modules/playwright/cli.js install chromium --with-deps \ && chown -R node:node /home/node/.cache \ @@ -284,15 +284,15 @@ COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright- COPY --from=builder /app/node_modules/playwright ./node_modules/playwright # Install system dependencies required by openclaw (git+ssh references). -RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \ +RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \ apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \ && rm -rf /var/lib/apt/lists/* \ && git config --system url."https://github.com/".insteadOf "ssh://git@github.com/" # Install CLI tools globally. Separate layer from apt for better cache reuse. -RUN --mount=type=cache,id=npm-cache,target=/root/.npm \ +RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \ npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest USER node diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 0eeccec17d..acc85e2c43 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -1881,7 +1881,6 @@ export function createSSEStream(options: StreamOptions = {}) { passthroughSawFinishReason = true; } - if (isFinishChunk && passthroughHasToolCalls) { toolFinishTime = now; try { @@ -2220,7 +2219,8 @@ export function createSSEStream(options: StreamOptions = {}) { }, pushProviderPayload: (payload: unknown) => providerPayloadCollector.push(payload), pushClientPayload: (payload: unknown) => clientPayloadCollector.push(payload), - sanitizeUsagePayload: (payload: unknown) => sanitizeUsagePayloadForRequest(payload, body, clientResponseFormat), + sanitizeUsagePayload: (payload: unknown) => + sanitizeUsagePayloadForRequest(payload, body, clientResponseFormat), setPassthroughResponsesId: (value: string) => { passthroughResponsesId = value; }, @@ -2281,7 +2281,8 @@ export function createSSEStream(options: StreamOptions = {}) { const bufferedPayload = parseSSELine(bufferedLine); if (bufferedPayload) { providerPayloadCollector.push(bufferedPayload); - if (sanitizeUsagePayloadForRequest(bufferedPayload, body, clientResponseFormat)) output = `data: ${JSON.stringify(bufferedPayload)}\n\n`; + if (sanitizeUsagePayloadForRequest(bufferedPayload, body, clientResponseFormat)) + output = `data: ${JSON.stringify(bufferedPayload)}\n\n`; if ( shouldInjectClaudeEmptyResponseBeforeCurrentEvent( claudeEmptyResponseLifecycle, From 757b19554074d199cd04e524bf4b0a06b22a84d8 Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 16 Aug 2026 12:14:45 +0900 Subject: [PATCH 09/36] perf(electron): bound lightweight readiness polling (#10324) --- electron/lib/serverReadiness.js | 61 ++++++++++++++++++++ electron/main.js | 34 +++-------- electron/package.json | 1 + tests/unit/electron-main.test.ts | 50 ++++++++-------- tests/unit/electron-server-readiness.test.ts | 57 ++++++++++++++++++ 5 files changed, 150 insertions(+), 53 deletions(-) create mode 100644 electron/lib/serverReadiness.js create mode 100644 tests/unit/electron-server-readiness.test.ts diff --git a/electron/lib/serverReadiness.js b/electron/lib/serverReadiness.js new file mode 100644 index 0000000000..0ae9eb24b2 --- /dev/null +++ b/electron/lib/serverReadiness.js @@ -0,0 +1,61 @@ +/** + * Pure helpers for polling the embedded or remote OmniRoute server without + * importing the Electron main process. + */ + +const DEFAULT_TIMEOUT_MS = 180000; +const DEFAULT_REQUEST_TIMEOUT_MS = 2000; +const DEFAULT_POLL_INTERVAL_MS = 500; + +function buildReadinessUrl(baseUrl) { + return `${baseUrl.replace(/\/+$/, "")}/api/health/ping`; +} + +async function waitForServer(url, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) { + const { + fetchFn = globalThis.fetch, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + nowFn = Date.now, + sleepFn = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), + warnFn = console.warn, + } = options; + + const startedAt = nowFn(); + while (nowFn() - startedAt < timeoutMs) { + const remainingMs = timeoutMs - (nowFn() - startedAt); + const attemptTimeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs)); + const controller = new AbortController(); + let timeoutId; + + try { + const response = await Promise.race([ + fetchFn(url, { signal: controller.signal }), + new Promise((resolve) => { + timeoutId = setTimeout(() => { + controller.abort(); + resolve(null); + }, attemptTimeoutMs); + }), + ]); + + if (response?.ok) return true; + } catch { + /* server not ready yet */ + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } + + const pollRemainingMs = timeoutMs - (nowFn() - startedAt); + if (pollRemainingMs <= 0) break; + await sleepFn(Math.min(pollIntervalMs, pollRemainingMs)); + } + + warnFn("[Electron] Server readiness timeout — showing window anyway"); + return false; +} + +module.exports = { + buildReadinessUrl, + waitForServer, +}; diff --git a/electron/main.js b/electron/main.js index b98692b295..7947ad9cb3 100644 --- a/electron/main.js +++ b/electron/main.js @@ -39,6 +39,7 @@ const { resolveServerEntry } = require("./lib/resolveServerEntry"); const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper"); const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl"); const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences"); +const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -86,6 +87,7 @@ let remoteServerUrl = resolveRemoteServerUrl({ }); const getServerUrl = () => remoteServerUrl || `http://localhost:${serverPort}`; +const getServerReadinessUrl = () => buildReadinessUrl(getServerUrl()); function resolveNodeExecutable(env = process.env) { // #1081: Ensure Next.js standalone runs using Electron's Node runtime @@ -185,26 +187,6 @@ function sendToRenderer(channel, data) { } } -// ── Helper: Wait for server readiness (#1, #10) ──────────── -// Default raised to 180s: the first launch after an upgrade can run long DB -// migrations, during which the server accepts the TCP connection but holds the -// HTTP response until handlers initialize. The previous 30s cap timed out and -// left the window stuck on a hanging connection (#2460). -async function waitForServer(url, timeoutMs = 180000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch(url); - if (res.ok || res.status < 500) return true; - } catch { - /* server not ready yet */ - } - await new Promise((r) => setTimeout(r, 500)); - } - console.warn("[Electron] Server readiness timeout — showing window anyway"); - return false; -} - // ── Helper: Wait for server process exit with timeout (#2) ─ async function waitForServerExit(proc, timeoutMs = 5000) { if (!proc) return; @@ -533,7 +515,7 @@ async function changePort(newPort) { // Start server on new port startNextServer(); - await waitForServer(getServerUrl()); + await waitForServer(getServerReadinessUrl()); // Reload window and update tray if (mainWindow && !mainWindow.isDestroyed()) { @@ -603,7 +585,7 @@ async function setRemoteServerUrl(nextUrl) { startNextServer(); try { - await waitForServer(`${getServerUrl()}/api/monitoring/health`); + await waitForServer(getServerReadinessUrl()); } catch (err) { console.warn("[Electron] Server did not become ready after remote-server change:", err.message); } @@ -935,7 +917,7 @@ function setupIpcHandlers() { stopNextServer(); await waitForServerExit(serverToStop); startNextServer(); - await waitForServer(getServerUrl()); + await waitForServer(getServerReadinessUrl()); return { success: true }; }); @@ -1078,8 +1060,8 @@ app.whenReady().then(async () => { startNextServer(); let serverReady = true; if (!isDev) { - // Probe the auth-exempt health endpoint (not the root URL, which may redirect). - serverReady = await waitForServer(`${getServerUrl()}/api/monitoring/health`); + // Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state. + serverReady = await waitForServer(getServerReadinessUrl()); } if (isHeadless) { @@ -1095,7 +1077,7 @@ app.whenReady().then(async () => { // If readiness timed out (e.g. very long first-launch migrations), don't leave the // window stuck on a hanging connection — keep polling and reload once it responds (#2460). if (!isDev && !serverReady && !isHeadless) { - void waitForServer(`${getServerUrl()}/api/monitoring/health`, 300000).then((ready) => { + void waitForServer(getServerReadinessUrl(), 300000).then((ready) => { if (ready && mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL(getServerUrl()); } diff --git a/electron/package.json b/electron/package.json index 4c98337587..132811c4dc 100644 --- a/electron/package.json +++ b/electron/package.json @@ -66,6 +66,7 @@ "lib/resolveNodeHelper.js", "lib/resolveRemoteServerUrl.js", "lib/remoteServerPreferences.js", + "lib/serverReadiness.js", "assets/remoteServerPrompt.html", "package.json", "node_modules/**/*" diff --git a/tests/unit/electron-main.test.ts b/tests/unit/electron-main.test.ts index 7ba0746f22..b8da073bdd 100644 --- a/tests/unit/electron-main.test.ts +++ b/tests/unit/electron-main.test.ts @@ -19,6 +19,7 @@ import { join } from "node:path"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); +const { waitForServer } = require("../../electron/lib/serverReadiness"); function raceDelays(firstMs, secondMs) { return new Promise((resolve) => { @@ -272,23 +273,12 @@ describe("Server Port Management", () => { describe("Server Readiness Logic", () => { it("waitForServer should timeout and return false", async () => { - // Simulate the polling logic with an always-failing fetch - async function waitForServer(url, timeoutMs = 100) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch(url); - if (res.ok || res.status < 500) return true; - } catch { - /* not ready */ - } - await new Promise((r) => setTimeout(r, 30)); - } - return false; - } - - // Should timeout immediately since nothing is running on that port - const result = await waitForServer("http://localhost:59999", 100); + const result = await waitForServer("http://localhost:59999/api/health/ping", 20, { + fetchFn: async () => ({ ok: false }), + pollIntervalMs: 1, + requestTimeoutMs: 5, + warnFn: () => {}, + }); assert.equal(result, false); }); @@ -302,18 +292,20 @@ describe("Server Readiness Logic", () => { serverUp = true; }, 60); - async function waitForServer(_url, timeoutMs) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (serverUp) return true; - await new Promise((r) => setTimeout(r, 15)); - } - return false; - } + const readinessOptions = { + fetchFn: async () => ({ ok: serverUp }), + pollIntervalMs: 5, + requestTimeoutMs: 5, + warnFn: () => {}, + }; try { // Initial probe with a short budget times out (server not up yet). - const initialReady = await waitForServer("http://localhost/api/monitoring/health", 20); + const initialReady = await waitForServer( + "http://localhost/api/health/ping", + 20, + readinessOptions + ); assert.equal(initialReady, false); let reloaded = false; @@ -325,7 +317,11 @@ describe("Server Readiness Logic", () => { }; // Background retry with a generous budget should succeed and reload the window. - const retryReady = await waitForServer("http://localhost/api/monitoring/health", 5000); + const retryReady = await waitForServer( + "http://localhost/api/health/ping", + 5000, + readinessOptions + ); if (retryReady && mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL("http://localhost"); } diff --git a/tests/unit/electron-server-readiness.test.ts b/tests/unit/electron-server-readiness.test.ts new file mode 100644 index 0000000000..323291ed4d --- /dev/null +++ b/tests/unit/electron-server-readiness.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { describe, it } from "node:test"; + +const require = createRequire(import.meta.url); +const { buildReadinessUrl, waitForServer } = require("../../electron/lib/serverReadiness"); + +describe("Electron server readiness", () => { + it("builds the lightweight readiness URL from local and remote base URLs", () => { + assert.equal( + buildReadinessUrl("http://localhost:20128"), + "http://localhost:20128/api/health/ping" + ); + assert.equal( + buildReadinessUrl("https://omniroute.example.com/"), + "https://omniroute.example.com/api/health/ping" + ); + }); + + it("accepts only a successful HTTP response", async () => { + let attempts = 0; + const ready = await waitForServer("http://localhost/api/health/ping", 100, { + fetchFn: async () => ({ ok: ++attempts === 2 }), + pollIntervalMs: 1, + requestTimeoutMs: 20, + warnFn: () => {}, + }); + + assert.equal(ready, true); + assert.equal(attempts, 2); + }); + + it("returns false after repeated unsuccessful responses", async () => { + const ready = await waitForServer("http://localhost/api/health/ping", 20, { + fetchFn: async () => ({ ok: false }), + pollIntervalMs: 1, + requestTimeoutMs: 5, + warnFn: () => {}, + }); + + assert.equal(ready, false); + }); + + it("bounds a stalled request by both the attempt and overall deadlines", async () => { + const startedAt = Date.now(); + const ready = await waitForServer("http://localhost/api/health/ping", 35, { + fetchFn: () => new Promise(() => {}), + pollIntervalMs: 1, + requestTimeoutMs: 10, + warnFn: () => {}, + }); + const elapsedMs = Date.now() - startedAt; + + assert.equal(ready, false); + assert.ok(elapsedMs < 150, `stalled readiness probe took ${elapsedMs}ms`); + }); +}); From 47f53f37eac1f1d33992e0316972c445f04b3d7b Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 16 Aug 2026 12:14:50 +0900 Subject: [PATCH 10/36] ci(electron): streamline release dependency setup (#10325) --- .github/workflows/electron-release.yml | 10 +- electron/package-lock.json | 195 ++++++++++++++++++ electron/package.json | 8 - tests/unit/electron-packaging.test.ts | 23 +-- .../unit/electron-release-efficiency.test.ts | 29 +++ 5 files changed, 236 insertions(+), 29 deletions(-) create mode 100644 tests/unit/electron-release-efficiency.test.ts diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index f1e3f869e6..fdc1a29d29 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -93,14 +93,6 @@ jobs: node-version: 24 cache: npm - - name: Cache node_modules - uses: actions/cache@v6.1.0 - with: - path: node_modules - key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- - - name: Install dependencies run: npm ci env: @@ -158,7 +150,7 @@ jobs: - name: Install Electron dependencies working-directory: electron - run: npm install --no-audit --no-fund + run: npm ci --no-audit --no-fund - name: Build Electron for ${{ matrix.platform }} working-directory: electron diff --git a/electron/package-lock.json b/electron/package-lock.json index cef8fecab5..262e136645 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -297,6 +297,45 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1091,6 +1130,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1411,6 +1459,19 @@ "node": ">=14.0.0" } }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } + }, "node_modules/electron-publish": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", @@ -1445,6 +1506,66 @@ "tiny-typed-emitter": "^2.1.0" } }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2359,6 +2480,20 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2622,6 +2757,36 @@ "node": ">=18" } }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/proc-log": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", @@ -2816,6 +2981,21 @@ "node": ">= 4" } }, + "node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -3071,6 +3251,21 @@ "node": ">=18" } }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", diff --git a/electron/package.json b/electron/package.json index 132811c4dc..4d475a4076 100644 --- a/electron/package.json +++ b/electron/package.json @@ -75,14 +75,6 @@ { "from": "../.build/electron-standalone", "to": "app", - "filter": [ - "**/*", - "node_modules/**/*" - ] - }, - { - "from": "../.build/electron-standalone/node_modules", - "to": "app/node_modules", "filter": [ "**/*" ] diff --git a/tests/unit/electron-packaging.test.ts b/tests/unit/electron-packaging.test.ts index 3d06cdd9b2..49db9e30f7 100644 --- a/tests/unit/electron-packaging.test.ts +++ b/tests/unit/electron-packaging.test.ts @@ -5,24 +5,23 @@ import test from "node:test"; const ROOT = join(import.meta.dirname, "..", ".."); -test("electron build copies standalone runtime dependencies into resources/app/node_modules", () => { +test("electron build copies the standalone runtime into resources/app exactly once", () => { const electronPackage = JSON.parse(readFileSync(join(ROOT, "electron", "package.json"), "utf8")); const extraResources = electronPackage.build?.extraResources; assert.ok(Array.isArray(extraResources), "electron build.extraResources must be an array"); - assert.deepEqual( - extraResources.find( - (resource) => - resource?.from === "../.build/electron-standalone/node_modules" && - resource?.to === "app/node_modules" - ), - { - from: "../.build/electron-standalone/node_modules", - to: "app/node_modules", - filter: ["**/*"], - } + const appResources = extraResources.filter( + (resource) => resource?.to === "app" || resource?.to?.startsWith("app/") ); + + assert.deepEqual(appResources, [ + { + from: "../.build/electron-standalone", + to: "app", + filter: ["**/*"], + }, + ]); }); test("electron standalone assembly normalizes Turbopack hashed external imports", () => { diff --git a/tests/unit/electron-release-efficiency.test.ts b/tests/unit/electron-release-efficiency.test.ts new file mode 100644 index 0000000000..6e19cfc244 --- /dev/null +++ b/tests/unit/electron-release-efficiency.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +const ROOT = join(import.meta.dirname, "..", ".."); +const workflow = readFileSync(join(ROOT, ".github", "workflows", "electron-release.yml"), "utf8"); + +test("Electron release relies on setup-node's npm cache instead of caching node_modules", () => { + assert.doesNotMatch(workflow, /path:\s*node_modules/); + assert.match(workflow, /uses:\s*actions\/setup-node@[^\n]+[\s\S]*?cache:\s*npm/); +}); + +test("Electron release installs both dependency trees deterministically", () => { + assert.match(workflow, /- name: Install dependencies\s+run: npm ci/); + assert.match( + workflow, + /- name: Install Electron dependencies\s+working-directory: electron\s+run: npm ci --no-audit --no-fund/ + ); + assert.doesNotMatch(workflow, /run:\s*npm install --no-audit --no-fund/); +}); + +test("Electron release retains packaged-app smoke coverage", () => { + assert.match(workflow, /- name: Smoke packaged Electron app\s+if: matrix\.platform != 'linux'/); + assert.match( + workflow, + /- name: Smoke packaged Electron app \(Linux\)\s+if: matrix\.platform == 'linux'/ + ); +}); From 0b347eaea15d867dcea861125aa93c97eb19651b Mon Sep 17 00:00:00 2001 From: Aman <1402357+Zartharas@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:14:55 -0600 Subject: [PATCH 11/36] fix(providers): validate Z.ai web auth semantics (#10329) --- .../fixes/10329-zai-web-auth-semantics.md | 1 + src/lib/providers/validation.ts | 4 +- src/lib/providers/validation/zaiWeb.ts | 52 +++++++++ tests/unit/zai-web-auth-semantics.test.ts | 108 ++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/10329-zai-web-auth-semantics.md create mode 100644 src/lib/providers/validation/zaiWeb.ts create mode 100644 tests/unit/zai-web-auth-semantics.test.ts diff --git a/changelog.d/fixes/10329-zai-web-auth-semantics.md b/changelog.d/fixes/10329-zai-web-auth-semantics.md new file mode 100644 index 0000000000..c4e6703112 --- /dev/null +++ b/changelog.d/fixes/10329-zai-web-auth-semantics.md @@ -0,0 +1 @@ +- **fix(providers):** validate Z.ai web Local Storage sessions against the authenticated user-settings endpoint and preserve exact upstream status codes ([#10329](https://github.com/diegosouzapw/OmniRoute/pull/10329)) — thanks @Zartharas diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index 2d05ece895..5efff93cb9 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -81,6 +81,7 @@ import { validatePoeProvider, } from "./validation/audioMiscProviders"; import { validateChatGptWebCodexProvider } from "./validation/chatgptWebCodex"; +import { validateZaiWebProvider } from "./validation/zaiWeb"; import { validateSearchProvider, SEARCH_VALIDATOR_CONFIGS } from "./validation/searchProviders"; import { validateClarifaiProvider, @@ -227,7 +228,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi error: "Modal requires a Base URL pointing to your OpenAI-compatible Modal app " + "(e.g. https://--.modal.run/v1). " + - "Fill in the \"Base URL override\" field.", + 'Fill in the "Base URL override" field.', }; } return validateOpenAILikeProvider({ @@ -249,6 +250,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi snowflake: validateSnowflakeProvider, gigachat: validateGigachatProvider, "deepseek-web": validateDeepSeekWebProvider, + "zai-web": validateZaiWebProvider, "grok-web": validateGrokWebProvider, "qwen-web": validateQwenWebProvider, "kimi-web": validateKimiWebProvider, diff --git a/src/lib/providers/validation/zaiWeb.ts b/src/lib/providers/validation/zaiWeb.ts new file mode 100644 index 0000000000..6d151d4130 --- /dev/null +++ b/src/lib/providers/validation/zaiWeb.ts @@ -0,0 +1,52 @@ +import { extractZaiToken } from "@omniroute/open-sse/services/zaiWebCredentials.ts"; +import { toValidationErrorResult, validationRead } from "./transport"; + +const ZAI_SESSION_PROBE_URL = "https://chat.z.ai/api/v1/users/user/settings"; + +export async function validateZaiWebProvider({ apiKey }: { apiKey?: string }) { + const token = extractZaiToken(String(apiKey || "")); + + if (!token) { + return { + valid: false, + error: + 'Invalid Z.ai web-session credential — copy the "token" value from chat.z.ai Local Storage.', + }; + } + + try { + const response = await validationRead(ZAI_SESSION_PROBE_URL, { + method: "GET", + headers: { + Accept: "application/json, text/plain, */*", + Authorization: `Bearer ${token}`, + Origin: "https://chat.z.ai", + Referer: "https://chat.z.ai/", + }, + }); + + if (response.status >= 200 && response.status < 300) { + return { + valid: true, + error: null, + }; + } + + if (response.status === 401) { + return { + valid: false, + error: + 'Invalid or expired Z.ai web-session credential — copy a fresh "token" value from chat.z.ai Local Storage.', + statusCode: 401, + }; + } + + return { + valid: false, + error: `Z.ai session validation returned HTTP ${response.status}`, + statusCode: response.status, + }; + } catch (error: unknown) { + return toValidationErrorResult(error); + } +} diff --git a/tests/unit/zai-web-auth-semantics.test.ts b/tests/unit/zai-web-auth-semantics.test.ts new file mode 100644 index 0000000000..ff0561f718 --- /dev/null +++ b/tests/unit/zai-web-auth-semantics.test.ts @@ -0,0 +1,108 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const originalFetch = globalThis.fetch; + +const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + +let nextStatus = 200; +let lastRequest: { + url: string; + method: string; + authorization: string; + cookie: string; +} | null = null; + +let lastResponse: Response | null = null; + +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + + lastRequest = { + url: String(input), + method: String(init?.method || "GET"), + authorization: headers.get("authorization") || "", + cookie: headers.get("cookie") || "", + }; + + lastResponse = new Response( + JSON.stringify({ + secret: "response-body-must-not-be-consumed", + }), + { + status: nextStatus, + headers: { + "content-type": "application/json", + }, + } + ); + + return lastResponse; +}) as typeof fetch; + +test.after(() => { + globalThis.fetch = originalFetch; +}); + +async function validate(status: number) { + nextStatus = status; + lastRequest = null; + lastResponse = null; + + return validateProviderApiKey({ + provider: "zai-web", + apiKey: "synthetic-zai-token", + providerSpecificData: {}, + }); +} + +test("zai-web uses the token-only authenticated user-settings GET", async () => { + const result = await validate(200); + + assert.equal(result.valid, true); + + assert.equal(lastRequest?.url, "https://chat.z.ai/api/v1/users/user/settings"); + + assert.equal(lastRequest?.method, "GET"); + + assert.equal(lastRequest?.authorization, "Bearer synthetic-zai-token"); + + assert.equal(lastRequest?.cookie, ""); + + assert.equal(lastResponse?.bodyUsed, false); +}); + +test("zai-web preserves exact 401 as credential rejection", async () => { + const result = await validate(401); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, 401); + + assert.match(result.error, /invalid or expired/i); + + assert.equal(lastResponse?.bodyUsed, false); +}); + +test("zai-web preserves 403 without calling it expired", async () => { + const result = await validate(403); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, 403); + + assert.doesNotMatch(result.error, /invalid or expired/i); + + assert.equal(lastResponse?.bodyUsed, false); +}); + +test("zai-web preserves rate-limit and server statuses", async () => { + for (const status of [429, 503]) { + const result = await validate(status); + + assert.equal(result.valid, false); + assert.equal(result.statusCode, status); + + assert.doesNotMatch(result.error, /invalid or expired/i); + + assert.equal(lastResponse?.bodyUsed, false); + } +}); From 4b76d3b76f942b96981c55703f68b27b088fb6ba Mon Sep 17 00:00:00 2001 From: Markus Hartung Date: Sun, 16 Aug 2026 05:15:38 +0200 Subject: [PATCH 12/36] fix(sse): close the synthetic keepalive reasoning item + harden output_index allocation (#10330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sse): close the synthetic keepalive reasoning item's output_item RESPONSES_STARTUP_THINKING_FRAME (the /v1/responses early-keepalive placeholder for slow-starting reasoning models) opened a synthetic "rs_keepalive" reasoning item at output_index 0 and closed its nested summary part (response.reasoning_summary_part.done), but never sent response.output_item.done to close the item itself. The comment claimed it was "closed within this one frame" — that was true for the part, not the item. Since this placeholder has no real upstream counterpart (the real response starts an independent response.created lifecycle later and never touches it), nothing else ever closes it. A client tracking open items by output_index (as the Responses API spec requires — this is exactly what OpenClaw's parser does) sees index 0 still open when the real response's own output_item.added later reuses that same index, and throws a collision. Live incident (2026-08-13, reliably reproducing by 2026-08-14): traced via a live tcpdump capture on the OmniRoute-dev container's network namespace, correlated against the OpenClaw gateway journal and 10 separate real request/response pairs (all wire-clean on the response side, ruling out provider corruption). The failing request's own outbound payload confirmed a replayed reasoning item without encrypted_content feeding a continuation call; the response wire bytes for that exact exchange showed rs_keepalive's output_item.added at index 0, then response.created/response.in_progress arriving *after* it, then a second output_item.added reusing index 0 for the real reasoning item — never preceded by an output_item.done for rs_keepalive. Reported upstream as OpenClaw issue #123342 before the OmniRoute-side root cause was found. Fix: emit response.output_item.done for the synthetic item, matching its already-buffered summary text, right after the summary part closes and before the frame ends. Test plan: - tests/unit/early-stream-keepalive.test.ts: updated the frame-shape test to assert the full 5-event closed sequence (added the missing output_item.done and its field assertions); confirmed it fails against pre-fix code (only 4 events) and passes after - node --test tests/unit/early-stream-keepalive.test.ts, tests/unit/earlyStreamKeepalive.test.ts, tests/unit/keepalive-cleanup-8140.test.ts, tests/unit/chat-body-admission.test.ts: 58 passed, 2 pre-existing skips unrelated to this change (Node test runner ReadableStream-error-simulation limitation) - tsgo --noEmit: clean on both touched files * fix(sse): allocate the keepalive output_index from a stack, not a literal Follow-up to 03f8345ac. That commit patched the specific symptom (added the missing response.output_item.done). This commit fixes the class: RESPONSES_STARTUP_THINKING_FRAME hardcoded output_index: 0 as a literal across five hand-written events, which is exactly how the missing-close bug happened in the first place — nothing enforced that every open got a matching close, so it silently didn't for months. ResponsesOutputIndexStack (open-sse/utils/responsesOutputIndexStack.ts) makes that structural: open() allocates the next sequential index, close() must name the index being closed and throws if it doesn't match the stack's top, and assertAllClosed() throws if anything is still open. The keepalive frame now calls assertAllClosed() at module load, so a future regression of this exact shape fails at import/boot time instead of shipping a malformed stream to production and surfacing days later as a live incident. Also adds tests/helpers/assertResponsesOutputIndexLifecycle.ts: a reusable version of the same invariant for replaying a full SSE event sequence (not just checking one frame's own shape), mirroring what a real client's output-index tracker enforces. Existing coverage for this bug class (responses-reasoning-close-before-message-466.test.ts) only asserted it by hand for one specific emitter (the real translator); nothing generic existed for a hand-rolled synthetic frame like this keepalive to be checked against, which is why its own test could pass while the actual downstream contract still failed. Wired into early-stream-keepalive.test.ts, including a test that concatenates the keepalive frame with a plausible real subsequent response and asserts no collision — the scenario that actually reproduced live, not just the frame's own internal shape. Test plan: - tests/unit/responses-output-index-stack.test.ts (new): open/close/ assertAllClosed behavior, including the exact mismatch and never-closed shapes this incident hit - tests/unit/early-stream-keepalive.test.ts: existing frame-shape test plus new collision-simulation test, both passing - node --test across responses-output-index-stack, early-stream-keepalive, earlyStreamKeepalive, keepalive-cleanup-8140, chat-body-admission: 65 passed, 2 pre-existing skips unrelated to this change - tsgo --noEmit: clean on all touched files --------- Co-authored-by: adevwithpurpose --- open-sse/utils/earlyStreamKeepalive.ts | 133 +++++++++++------- open-sse/utils/responsesOutputIndexStack.ts | 48 +++++++ .../assertResponsesOutputIndexLifecycle.ts | 55 ++++++++ tests/unit/early-stream-keepalive.test.ts | 65 ++++++++- .../unit/responses-output-index-stack.test.ts | 46 ++++++ 5 files changed, 295 insertions(+), 52 deletions(-) create mode 100644 open-sse/utils/responsesOutputIndexStack.ts create mode 100644 tests/helpers/assertResponsesOutputIndexLifecycle.ts create mode 100644 tests/unit/responses-output-index-stack.test.ts diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index b8c3c631f8..fe9964e477 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -31,6 +31,8 @@ * to 200, so the HTTP status can no longer change). */ +import { ResponsesOutputIndexStack } from "./responsesOutputIndexStack.ts"; + const ENCODER = new TextEncoder(); const KEEPALIVE_FRAME = ENCODER.encode(": keepalive\n\n"); // OpenAI-compatible keepalive: a syntactically valid empty streaming chunk. @@ -50,59 +52,89 @@ export const OPENAI_STARTUP_FRAME = OPENAI_KEEPALIVE_FRAME; // API emits `event: ping` for exactly this reason; the /v1/messages route mirrors it. export const ANTHROPIC_PING_FRAME = ENCODER.encode('event: ping\ndata: {"type":"ping"}\n\n'); // Responses API keepalive: a self-contained, self-closed synthetic reasoning -// item (added -> summary_part.added -> text.delta -> summary_part.done), -// matching the abbreviated close pattern open-sse/utils/stream.ts's own -// emitSyntheticResponsesReasoningSummary already uses for real mid-stream -// reasoning. Closed within this one frame (not left dangling open) since the -// real upstream response — once it arrives — starts its own independent -// response.created lifecycle from scratch; this placeholder item never -// carries a response_id and isn't meant to be continued. +// item (added -> summary_part.added -> text.delta -> summary_part.done -> +// output_item.done). Unlike open-sse/utils/stream.ts's own +// emitSyntheticResponsesReasoningSummary — which only supplements a REAL +// upstream item that the real provider stream will close on its own — this +// placeholder item has no real counterpart: the upstream response, once it +// arrives, starts its own independent response.created lifecycle from +// scratch and will never close this one. It must therefore send its own +// response.output_item.done here, not just reasoning_summary_part.done +// (that only closes the nested summary part, not the output item itself). +// Without it, a strict client tracking open items by output_index (as the +// Responses API spec requires) sees this item still open at index 0 and +// throws a collision the moment the real response's own output_item.added +// reuses that same index — reproduced live 2026-08-13, OpenClaw issue +// https://github.com/openclaw/openclaw/issues/123342. +// +// The output_index is allocated from ResponsesOutputIndexStack instead of a +// hardcoded literal so this stays structurally correct: forgetting the +// close() call throws at module load (assertAllClosed() below), not +// silently at some future real request. const RESPONSES_STARTUP_ITEM_ID = "rs_keepalive"; // Brand-neutral placeholder — clients persist this as visible reasoning. const STARTUP_THINKING_TEXT = "✨"; +const startupIndexStack = new ResponsesOutputIndexStack(); +const RESPONSES_STARTUP_OUTPUT_INDEX = startupIndexStack.open(); +const startupEvents = [ + { + event: "response.output_item.added", + data: { + type: "response.output_item.added", + output_index: RESPONSES_STARTUP_OUTPUT_INDEX, + item: { id: RESPONSES_STARTUP_ITEM_ID, type: "reasoning", summary: [] }, + }, + }, + { + event: "response.reasoning_summary_part.added", + data: { + type: "response.reasoning_summary_part.added", + item_id: RESPONSES_STARTUP_ITEM_ID, + output_index: RESPONSES_STARTUP_OUTPUT_INDEX, + summary_index: 0, + part: { type: "summary_text", text: "" }, + }, + }, + { + event: "response.reasoning_summary_text.delta", + data: { + type: "response.reasoning_summary_text.delta", + item_id: RESPONSES_STARTUP_ITEM_ID, + output_index: RESPONSES_STARTUP_OUTPUT_INDEX, + summary_index: 0, + delta: STARTUP_THINKING_TEXT, + }, + }, + { + event: "response.reasoning_summary_part.done", + data: { + type: "response.reasoning_summary_part.done", + item_id: RESPONSES_STARTUP_ITEM_ID, + output_index: RESPONSES_STARTUP_OUTPUT_INDEX, + summary_index: 0, + part: { type: "summary_text", text: STARTUP_THINKING_TEXT }, + }, + }, +]; +// close() runs before the output_item.done event is built (not just before +// it's appended) so assertAllClosed() below is a real check, not scaffolding +// that always trivially passes. +startupIndexStack.close(RESPONSES_STARTUP_OUTPUT_INDEX); +startupEvents.push({ + event: "response.output_item.done", + data: { + type: "response.output_item.done", + output_index: RESPONSES_STARTUP_OUTPUT_INDEX, + item: { + id: RESPONSES_STARTUP_ITEM_ID, + type: "reasoning", + summary: [{ type: "summary_text", text: STARTUP_THINKING_TEXT }], + }, + }, +}); +startupIndexStack.assertAllClosed(); export const RESPONSES_STARTUP_THINKING_FRAME = ENCODER.encode( - [ - { - event: "response.output_item.added", - data: { - type: "response.output_item.added", - output_index: 0, - item: { id: RESPONSES_STARTUP_ITEM_ID, type: "reasoning", summary: [] }, - }, - }, - { - event: "response.reasoning_summary_part.added", - data: { - type: "response.reasoning_summary_part.added", - item_id: RESPONSES_STARTUP_ITEM_ID, - output_index: 0, - summary_index: 0, - part: { type: "summary_text", text: "" }, - }, - }, - { - event: "response.reasoning_summary_text.delta", - data: { - type: "response.reasoning_summary_text.delta", - item_id: RESPONSES_STARTUP_ITEM_ID, - output_index: 0, - summary_index: 0, - delta: STARTUP_THINKING_TEXT, - }, - }, - { - event: "response.reasoning_summary_part.done", - data: { - type: "response.reasoning_summary_part.done", - item_id: RESPONSES_STARTUP_ITEM_ID, - output_index: 0, - summary_index: 0, - part: { type: "summary_text", text: STARTUP_THINKING_TEXT }, - }, - }, - ] - .map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`) - .join("") + startupEvents.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`).join("") ); // Anthropic Messages API default — Anthropic's own spec really does use a named // `event: error` SSE frame, so this is correct there. It is WRONG for the OpenAI- @@ -184,8 +216,7 @@ export type EarlyStreamKeepaliveOptions = { * type-check. A string discriminant narrows both branches under the same settings. */ type SettledHandler = - | { status: "fulfilled"; response: Response } - | { status: "rejected"; error: unknown }; + { status: "fulfilled"; response: Response } | { status: "rejected"; error: unknown }; export async function withEarlyStreamKeepalive( handlerPromise: Promise, diff --git a/open-sse/utils/responsesOutputIndexStack.ts b/open-sse/utils/responsesOutputIndexStack.ts new file mode 100644 index 0000000000..5eab5b4ca8 --- /dev/null +++ b/open-sse/utils/responsesOutputIndexStack.ts @@ -0,0 +1,48 @@ +/** + * @file responsesOutputIndexStack.ts + * @description Structural guard against the Responses-API output_index + * collision bug class (OpenClaw issue #123342): a hand-tracked output_index + * that an emitter forgets to close before the same number gets reused. + * + * Responses-API output items open and close one at a time within any single + * emitter — there is never a real need to hold two indices open + * simultaneously from one emitter's own bookkeeping. Modeling allocation as + * a stack makes "forgot to close" a structural impossibility instead of a + * silent bug: open() always returns the next sequential index, close() + * requires the caller to name the index being closed and throws if it does + * not match the top of the stack, and assertAllClosed() — called once the + * caller has finished building its frame/events — throws if anything is + * still open. For a module-level constant frame (like the early keepalive + * placeholder), that last check runs at import time: a regression here fails + * the build/boot instead of shipping a malformed stream to production. + */ + +export class ResponsesOutputIndexStack { + private readonly openIndices: number[] = []; + private nextIndex = 0; + + open(): number { + const index = this.nextIndex; + this.nextIndex += 1; + this.openIndices.push(index); + return index; + } + + close(index: number): void { + const top = this.openIndices.at(-1); + if (top !== index) { + throw new Error( + `ResponsesOutputIndexStack: closing output_index ${index} but the open top was ${String(top)}` + ); + } + this.openIndices.pop(); + } + + assertAllClosed(): void { + if (this.openIndices.length > 0) { + throw new Error( + `ResponsesOutputIndexStack: output_index(es) still open with no close(): ${this.openIndices.join(", ")}` + ); + } + } +} diff --git a/tests/helpers/assertResponsesOutputIndexLifecycle.ts b/tests/helpers/assertResponsesOutputIndexLifecycle.ts new file mode 100644 index 0000000000..b1ae64fa9b --- /dev/null +++ b/tests/helpers/assertResponsesOutputIndexLifecycle.ts @@ -0,0 +1,55 @@ +/** + * Validates the Responses-API output_index lifecycle invariant that real + * clients (e.g. OpenClaw's outputSlots tracker) enforce: an output_index + * claimed by response.output_item.added must be closed by a matching + * response.output_item.done before any later item reuses that same index. + * + * Existing coverage (responses-reasoning-close-before-message-466.test.ts) + * asserts this invariant by hand for one specific emitter path (the real + * translator/transformer). This helper generalizes that check so any SSE + * event sequence — including hand-rolled synthetic frames like the early + * keepalive placeholder — can be verified against the same contract a real + * downstream client applies, without duplicating the tracking logic per test. + * + * Mirrors OpenClaw's createResponsesOutputSlotTracker() closely enough to + * reproduce the exact failure mode: "Responses stream reused active output + * index N" (see OpenClaw issue #123342 / the RESPONSES_STARTUP_THINKING_FRAME + * missing-output_item.done incident this helper was added for). + */ + +export type ResponsesLifecycleEvent = { event?: string; data: Record }; + +export function assertResponsesOutputIndexLifecycle( + events: ResponsesLifecycleEvent[], + options: { requireAllClosed?: boolean } = {} +): void { + const open = new Map(); + + for (const { data } of events) { + const type = data?.type; + if (type !== "response.output_item.added" && type !== "response.output_item.done") continue; + + const outputIndex = data.output_index; + if (typeof outputIndex !== "number") continue; + + if (type === "response.output_item.added") { + if (open.has(outputIndex)) { + const item = data.item as { id?: unknown; type?: unknown } | undefined; + throw new Error( + `Responses stream reused active output index ${outputIndex} ` + + `(item id=${String(item?.id)} type=${String(item?.type)} was still open)` + ); + } + open.set(outputIndex, data.item); + } else { + open.delete(outputIndex); + } + } + + if (options.requireAllClosed !== false && open.size > 0) { + const stillOpen = [...open.keys()].join(", "); + throw new Error( + `Responses stream left output index(es) open with no output_item.done: ${stillOpen}` + ); + } +} diff --git a/tests/unit/early-stream-keepalive.test.ts b/tests/unit/early-stream-keepalive.test.ts index 824b49cf44..c52514886f 100644 --- a/tests/unit/early-stream-keepalive.test.ts +++ b/tests/unit/early-stream-keepalive.test.ts @@ -17,6 +17,7 @@ import { OPENAI_CHAT_ERROR_FRAME, OPENAI_RESPONSES_ERROR_FRAME, } from "../../open-sse/utils/earlyStreamKeepalive.ts"; +import { assertResponsesOutputIndexLifecycle } from "../helpers/assertResponsesOutputIndexLifecycle.ts"; async function readAll(response: Response): Promise { const reader = response.body!.getReader(); @@ -201,10 +202,11 @@ test("RESPONSES_STARTUP_THINKING_FRAME is a self-closed synthetic reasoning item "response.reasoning_summary_part.added", "response.reasoning_summary_text.delta", "response.reasoning_summary_part.done", + "response.output_item.done", ] ); - const [added, partAdded, delta, partDone] = events; + const [added, partAdded, delta, partDone, itemDone] = events; assert.equal(added.data.item.type, "reasoning"); const itemId = added.data.item.id; assert.ok(itemId, "reasoning item must have an id"); @@ -214,6 +216,67 @@ test("RESPONSES_STARTUP_THINKING_FRAME is a self-closed synthetic reasoning item assert.equal(delta.data.delta, "✨"); assert.equal(partDone.data.item_id, itemId); assert.equal(partDone.data.part.text, "✨"); + + // Regression for the live 2026-08-13 incident (OpenClaw issue #123342): + // reasoning_summary_part.done only closes the nested summary part, not the + // output item itself. Without a matching response.output_item.done here, + // a client tracking open items by output_index still sees this synthetic + // item open at index 0 when the real upstream response later reuses that + // same index for its own response.output_item.added, and throws a + // collision ("Responses stream reused active output index 0"). + assert.equal(itemDone.data.output_index, added.data.output_index); + assert.equal(itemDone.data.item.id, itemId); + assert.equal(itemDone.data.item.type, "reasoning"); + + // General-purpose form of the same check: this frame alone must be a fully + // self-closed lifecycle (no output_item left open at the end). + assertResponsesOutputIndexLifecycle(events); +}); + +test("RESPONSES_STARTUP_THINKING_FRAME does not collide when the real upstream response reuses output_index 0", () => { + // Reproduces the actual live failure shape (OpenClaw issue #123342): the + // keepalive placeholder fires, then the real upstream response starts its + // own independent response.created lifecycle and reuses output_index 0 for + // its own real reasoning item. Concatenating the two and replaying them + // through the same output_index-lifecycle contract a real client enforces + // is what actually would have caught the missing output_item.done — the + // frame-shape-only test above could pass while this still failed. + const decoded = new TextDecoder().decode(RESPONSES_STARTUP_THINKING_FRAME); + const keepaliveEvents = decoded + .split("\n\n") + .filter(Boolean) + .map((frame) => { + const [eventLine, dataLine] = frame.split("\n"); + return { + event: eventLine.replace(/^event: /, ""), + data: JSON.parse(dataLine.replace(/^data: /, "")), + }; + }); + + const realResponseEvents = [ + { event: "response.created", data: { type: "response.created" } }, + { event: "response.in_progress", data: { type: "response.in_progress" } }, + { + event: "response.output_item.added", + data: { + type: "response.output_item.added", + output_index: 0, + item: { id: "rs_real", type: "reasoning", summary: [] }, + }, + }, + { + event: "response.output_item.done", + data: { + type: "response.output_item.done", + output_index: 0, + item: { id: "rs_real", type: "reasoning", summary: [] }, + }, + }, + ]; + + assert.doesNotThrow(() => + assertResponsesOutputIndexLifecycle([...keepaliveEvents, ...realResponseEvents]) + ); }); test("slow handler emits the Responses API startup frame before the real body", async () => { diff --git a/tests/unit/responses-output-index-stack.test.ts b/tests/unit/responses-output-index-stack.test.ts new file mode 100644 index 0000000000..0343868de8 --- /dev/null +++ b/tests/unit/responses-output-index-stack.test.ts @@ -0,0 +1,46 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { ResponsesOutputIndexStack } from "../../open-sse/utils/responsesOutputIndexStack.ts"; + +test("open() allocates sequential indices starting at 0", () => { + const stack = new ResponsesOutputIndexStack(); + assert.equal(stack.open(), 0); + assert.equal(stack.open(), 1); +}); + +test("close() on the current top does not throw", () => { + const stack = new ResponsesOutputIndexStack(); + const index = stack.open(); + assert.doesNotThrow(() => stack.close(index)); +}); + +test("close() with a mismatched index throws (catches the exact keepalive bug shape)", () => { + const stack = new ResponsesOutputIndexStack(); + const first = stack.open(); + stack.open(); + assert.throws(() => stack.close(first), /closing output_index 0 but the open top was 1/); +}); + +test("assertAllClosed() passes when everything opened was closed", () => { + const stack = new ResponsesOutputIndexStack(); + const index = stack.open(); + stack.close(index); + assert.doesNotThrow(() => stack.assertAllClosed()); +}); + +test("assertAllClosed() throws when an index was never closed — the exact regression this stack prevents", () => { + const stack = new ResponsesOutputIndexStack(); + stack.open(); + assert.throws(() => stack.assertAllClosed(), /still open with no close/); +}); + +test("a later open() after a forgotten close() gets the next index, never a reused one", () => { + // This is the structural guarantee replacing the old hand-tracked literal + // output_index: 0 in RESPONSES_STARTUP_THINKING_FRAME: even if a caller + // forgets to close(), the next open() can never collide with it. + const stack = new ResponsesOutputIndexStack(); + const first = stack.open(); + const second = stack.open(); + assert.notEqual(first, second); +}); From 684ea70fb307d073d0517aeb3defad89a5ec08cb Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 16 Aug 2026 12:15:42 +0900 Subject: [PATCH 13/36] perf(electron): prune authoring docs from packages (#10359) --- scripts/build/electronRuntimeDocs.mjs | 65 +++++++++++++++++ scripts/build/prepare-electron-standalone.mjs | 9 +++ tests/unit/electron-packaging.test.ts | 70 ++++++++++++++++++- 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 scripts/build/electronRuntimeDocs.mjs diff --git a/scripts/build/electronRuntimeDocs.mjs b/scripts/build/electronRuntimeDocs.mjs new file mode 100644 index 0000000000..b9d5a8aa10 --- /dev/null +++ b/scripts/build/electronRuntimeDocs.mjs @@ -0,0 +1,65 @@ +import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs"; +import { join, relative, resolve, sep } from "node:path"; + +export const ELECTRON_RUNTIME_DOC_PRUNE_RULES = Object.freeze({ + localeRootFiles: Object.freeze(["CHANGELOG.md"]), + authoringDirectories: Object.freeze(["docs/research", "docs/superpowers"]), +}); + +function payloadSize(targetPath) { + const stat = lstatSync(targetPath); + if (!stat.isDirectory()) { + return { files: 1, bytes: stat.size }; + } + + return readdirSync(targetPath).reduce( + (total, entry) => { + const payload = payloadSize(join(targetPath, entry)); + total.files += payload.files; + total.bytes += payload.bytes; + return total; + }, + { files: 0, bytes: 0 } + ); +} + +function removePayload(bundleRoot, relativePath, summary) { + const root = resolve(bundleRoot); + const targetPath = resolve(root, relativePath); + if (targetPath !== root && !targetPath.startsWith(`${root}${sep}`)) { + throw new Error(`[electron-docs] refusing to prune outside bundle root: ${relativePath}`); + } + if (!existsSync(targetPath)) return; + + const payload = payloadSize(targetPath); + rmSync(targetPath, { recursive: true, force: true }); + summary.removedFiles += payload.files; + summary.removedBytes += payload.bytes; + summary.removedPaths.push(relative(root, targetPath).split(sep).join("/")); +} + +/** + * Remove docs that are useful while authoring OmniRoute but are never read by + * the packaged desktop runtime. Canonical docs remain untouched; bundleRoot is + * the disposable Electron staging directory. + */ +export function pruneElectronRuntimeDocs(bundleRoot) { + const summary = { removedFiles: 0, removedBytes: 0, removedPaths: [] }; + const localesRoot = join(bundleRoot, "docs", "i18n"); + + if (existsSync(localesRoot)) { + for (const locale of readdirSync(localesRoot, { withFileTypes: true })) { + if (!locale.isDirectory()) continue; + for (const fileName of ELECTRON_RUNTIME_DOC_PRUNE_RULES.localeRootFiles) { + removePayload(bundleRoot, join("docs", "i18n", locale.name, fileName), summary); + } + } + } + + for (const relativePath of ELECTRON_RUNTIME_DOC_PRUNE_RULES.authoringDirectories) { + removePayload(bundleRoot, relativePath, summary); + } + + summary.removedPaths.sort(); + return summary; +} diff --git a/scripts/build/prepare-electron-standalone.mjs b/scripts/build/prepare-electron-standalone.mjs index e195f6480f..6265a94b31 100644 --- a/scripts/build/prepare-electron-standalone.mjs +++ b/scripts/build/prepare-electron-standalone.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { assembleStandalone } from "./assembleStandalone.mjs"; import { buildRebuildSpawnPlan } from "./electronRebuildPlan.mjs"; +import { pruneElectronRuntimeDocs } from "./electronRuntimeDocs.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -205,6 +206,14 @@ assembleStandalone({ materializeSymlinks: true, }); +const docsPrune = pruneElectronRuntimeDocs(ELECTRON_STANDALONE_DIR); +if (docsPrune.removedFiles > 0) { + console.log( + `[electron] pruned ${docsPrune.removedFiles} authoring doc file(s) ` + + `(${docsPrune.removedBytes} bytes) from the staging bundle` + ); +} + // Electron-UNIQUE post-assembly steps removeGeneratedElectronArtifacts(); diff --git a/tests/unit/electron-packaging.test.ts b/tests/unit/electron-packaging.test.ts index 49db9e30f7..1e231f9aca 100644 --- a/tests/unit/electron-packaging.test.ts +++ b/tests/unit/electron-packaging.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { pruneElectronRuntimeDocs } from "../../scripts/build/electronRuntimeDocs.mjs"; const ROOT = join(import.meta.dirname, "..", ".."); @@ -36,3 +38,69 @@ test("electron standalone assembly normalizes Turbopack hashed external imports" "Electron packages must strip Turbopack's hashed external package names before bundling" ); }); + +test("electron docs manifest prunes authoring payloads without removing runtime docs", () => { + const bundleRoot = mkdtempSync(join(tmpdir(), "omniroute-electron-docs-")); + const files = new Map([ + ["docs/openapi.yaml", "openapi: 3.1.0"], + ["docs/guides/CODEX-CLI-CONFIGURATION.md", "# Codex CLI"], + ["docs/i18n/ko/docs/guides/ELECTRON_GUIDE.md", "# Electron"], + ["docs/i18n/ko/CHANGELOG.md", "translated release history"], + ["docs/i18n/fr/CHANGELOG.md", "historique traduit"], + ["docs/research/desktop-notes.md", "authoring notes"], + ["docs/superpowers/plans/desktop-plan.md", "implementation plan"], + ]); + + try { + for (const [relativePath, content] of files) { + const absolutePath = join(bundleRoot, relativePath); + mkdirSync(join(absolutePath, ".."), { recursive: true }); + writeFileSync(absolutePath, content); + } + + const result = pruneElectronRuntimeDocs(bundleRoot); + + assert.deepEqual(result.removedPaths, [ + "docs/i18n/fr/CHANGELOG.md", + "docs/i18n/ko/CHANGELOG.md", + "docs/research", + "docs/superpowers", + ]); + assert.equal(result.removedFiles, 4); + assert.equal( + result.removedBytes, + Buffer.byteLength("translated release history") + + Buffer.byteLength("historique traduit") + + Buffer.byteLength("authoring notes") + + Buffer.byteLength("implementation plan") + ); + + assert.equal(existsSync(join(bundleRoot, "docs/openapi.yaml")), true); + assert.equal(existsSync(join(bundleRoot, "docs/guides/CODEX-CLI-CONFIGURATION.md")), true); + assert.equal(existsSync(join(bundleRoot, "docs/i18n/ko/docs/guides/ELECTRON_GUIDE.md")), true); + assert.equal(existsSync(join(bundleRoot, "docs/i18n/ko/CHANGELOG.md")), false); + assert.equal(existsSync(join(bundleRoot, "docs/research")), false); + assert.equal(existsSync(join(bundleRoot, "docs/superpowers")), false); + + assert.deepEqual(pruneElectronRuntimeDocs(bundleRoot), { + removedFiles: 0, + removedBytes: 0, + removedPaths: [], + }); + } finally { + rmSync(bundleRoot, { recursive: true, force: true }); + } +}); + +test("electron bundle preparation applies the runtime docs manifest to its staging tree", () => { + const prepareScript = readFileSync( + join(ROOT, "scripts", "build", "prepare-electron-standalone.mjs"), + "utf8" + ); + + assert.match( + prepareScript, + /pruneElectronRuntimeDocs\(ELECTRON_STANDALONE_DIR\)/, + "Electron staging must prune authoring docs before electron-builder copies the bundle" + ); +}); From b19e9772bc31b3db823eb63c12ee352e58828af6 Mon Sep 17 00:00:00 2001 From: tkgo11 <71800574+tkgo11@users.noreply.github.com> Date: Sun, 16 Aug 2026 12:15:46 +0900 Subject: [PATCH 14/36] fix(monitoring): canonicalize provider aliases in health matrix (#10370) * fix(monitoring): canonicalize provider aliases in health matrix * fix(monitoring): canonicalize aliases in health autopilot --------- Co-authored-by: tkgo11 <7.1800574e+07+tkgo11@users.noreply.github.com> --- src/lib/monitoring/providerHealthAutopilot.ts | 69 ++++++++++++------- src/lib/monitoring/providerHealthMatrix.ts | 44 ++++++++---- tests/unit/provider-health-matrix.test.ts | 63 +++++++++++++++++ .../serial/provider-health-autopilot.test.ts | 56 ++++++++++++++- 4 files changed, 190 insertions(+), 42 deletions(-) diff --git a/src/lib/monitoring/providerHealthAutopilot.ts b/src/lib/monitoring/providerHealthAutopilot.ts index 8fc54e2d86..e29844da36 100644 --- a/src/lib/monitoring/providerHealthAutopilot.ts +++ b/src/lib/monitoring/providerHealthAutopilot.ts @@ -1,11 +1,9 @@ import { createHash } from "crypto"; -import { - getProviderConnections, - updateProviderConnection, -} from "@/lib/db/providers"; +import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers"; import { getCachedProviderConnectionById } from "@/lib/localDb"; import { clearProviderFailure, clearModelLock } from "@omniroute/open-sse/services/accountFallback"; +import { resolveProviderAlias } from "@omniroute/open-sse/services/model"; type JsonRecord = Record; @@ -113,6 +111,11 @@ function toString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function canonicalProviderId(value: unknown): string | null { + const provider = toString(value); + return provider ? (resolveProviderAlias(provider) ?? provider) : null; +} + function toNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { @@ -253,7 +256,7 @@ export async function buildProviderHealthAutopilotReport( const checkedAt = new Date(now).toISOString(); const includeHealthy = options.includeHealthy === true; const includeActions = options.includeActions !== false; - const providerFilter = toString(options.provider); + const providerFilter = canonicalProviderId(options.provider); const [{ getAllCircuitBreakerStatuses }, { getAllModelLockouts }, quotaMonitor] = await Promise.all([ @@ -262,40 +265,45 @@ export async function buildProviderHealthAutopilotReport( import("@omniroute/open-sse/services/quotaMonitor.ts").catch(() => null), ]); - const connections = (await getProviderConnections( - providerFilter ? { provider: providerFilter } : {} - )) as JsonRecord[]; + // Connections generally use canonical ids, while breakers, lockouts, and quota + // snapshots can retain the alias used at dispatch time. Normalize the aggregation + // key, but preserve each raw source id for actions that must mutate runtime state. + const connections = ((await getProviderConnections({})) as JsonRecord[]).filter((connection) => { + const provider = canonicalProviderId(connection.provider); + return provider && (!providerFilter || provider === providerFilter); + }); const breakers = getAllCircuitBreakerStatuses().filter((breaker) => { const name = toString((breaker as JsonRecord).name); - if (!name || name.startsWith("test-") || name.startsWith("test_")) return false; - return !providerFilter || name === providerFilter; + const provider = canonicalProviderId(name); + if (!name || !provider || name.startsWith("test-") || name.startsWith("test_")) return false; + return !providerFilter || provider === providerFilter; }); const lockouts = (getAllModelLockouts() as JsonRecord[]).filter((lockout) => { - const provider = providerFromLockout(lockout); + const provider = canonicalProviderId(providerFromLockout(lockout)); return provider && (!providerFilter || provider === providerFilter); }); const quotaSnapshots = quotaMonitor?.getQuotaMonitorSnapshots ? (quotaMonitor.getQuotaMonitorSnapshots() as JsonRecord[]).filter((snapshot) => { - const provider = toString(snapshot.provider); + const provider = canonicalProviderId(snapshot.provider); return provider && (!providerFilter || provider === providerFilter); }) : []; const providerIds = new Set(); for (const connection of connections) { - const provider = toString(connection.provider); + const provider = canonicalProviderId(connection.provider); if (provider) providerIds.add(provider); } for (const breaker of breakers) { - const provider = toString((breaker as JsonRecord).name); + const provider = canonicalProviderId((breaker as JsonRecord).name); if (provider) providerIds.add(provider); } for (const lockout of lockouts) { - const provider = providerFromLockout(lockout); + const provider = canonicalProviderId(providerFromLockout(lockout)); if (provider) providerIds.add(provider); } for (const snapshot of quotaSnapshots) { - const provider = toString(snapshot.provider); + const provider = canonicalProviderId(snapshot.provider); if (provider) providerIds.add(provider); } if (providerFilter) providerIds.add(providerFilter); @@ -303,19 +311,21 @@ export async function buildProviderHealthAutopilotReport( const providers: ProviderAutopilotProvider[] = []; for (const provider of [...providerIds].sort()) { const providerConnections = connections.filter( - (connection) => connection.provider === provider + (connection) => canonicalProviderId(connection.provider) === provider ); - const breaker = breakers.find((entry) => (entry as JsonRecord).name === provider) as - | JsonRecord - | undefined; + const breaker = breakers.find( + (entry) => canonicalProviderId((entry as JsonRecord).name) === provider + ) as JsonRecord | undefined; const providerLockouts = lockouts.filter( - (lockout) => providerFromLockout(lockout) === provider + (lockout) => canonicalProviderId(providerFromLockout(lockout)) === provider + ); + const providerQuota = quotaSnapshots.filter( + (snapshot) => canonicalProviderId(snapshot.provider) === provider ); - const providerQuota = quotaSnapshots.filter((snapshot) => snapshot.provider === provider); const issues: ProviderAutopilotIssue[] = []; if (breaker && OPEN_BREAKER_STATES.has(String(breaker.state))) { - const target = { provider }; + const target = { provider: toString(breaker.name) ?? provider }; const evidence = { state: breaker.state, failureCount: toNumber(breaker.failureCount) ?? 0, @@ -342,7 +352,7 @@ export async function buildProviderHealthAutopilotReport( for (const connection of providerConnections) { const connectionId = toString(connection.id); if (!connectionId) continue; - const target = { provider, connectionId }; + const target = { provider: toString(connection.provider) ?? provider, connectionId }; const label = sanitizeConnectionLabel(connection); const cooldownUntil = parseTimeMs(connection.rateLimitedUntil); const terminal = isTerminalConnection(connection); @@ -450,7 +460,11 @@ export async function buildProviderHealthAutopilotReport( if (!connectionId || !model) continue; const connection = providerConnections.find((entry) => entry.id === connectionId); const terminalConnection = connection ? isTerminalConnection(connection) : false; - const target = { provider, connectionId, model }; + const target = { + provider: providerFromLockout(lockout) ?? provider, + connectionId, + model, + }; const evidence = { reason: lockout.reason ?? null, remainingMs: toNumber(lockout.remainingMs) ?? 0, @@ -483,7 +497,10 @@ export async function buildProviderHealthAutopilotReport( if (!status || !["warning", "exhausted", "error"].includes(status)) continue; const connectionId = toString(snapshot.accountId) ?? undefined; const sessionId = toString(snapshot.sessionId) ?? undefined; - const target = { provider, ...(connectionId ? { connectionId } : {}) }; + const target = { + provider: toString(snapshot.provider) ?? provider, + ...(connectionId ? { connectionId } : {}), + }; issues.push({ id: issueId("quota_monitor_warning", { ...target, diff --git a/src/lib/monitoring/providerHealthMatrix.ts b/src/lib/monitoring/providerHealthMatrix.ts index dcc99a3830..b1ff287bae 100644 --- a/src/lib/monitoring/providerHealthMatrix.ts +++ b/src/lib/monitoring/providerHealthMatrix.ts @@ -3,6 +3,7 @@ import { getProviderConnections } from "@/lib/db/providers"; import { getDbInstance } from "@/lib/db/core"; import { getAllCircuitBreakerStatuses } from "@/shared/utils/circuitBreaker"; import { getAllModelLockouts } from "@omniroute/open-sse/services/accountFallback"; +import { resolveProviderAlias } from "@omniroute/open-sse/services/model"; import { getWebSessionPoolHealth } from "@omniroute/open-sse/services/webSessionPoolHealth"; type JsonRecord = Record; @@ -133,6 +134,11 @@ function toString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function canonicalProviderId(value: unknown): string | null { + const provider = toString(value); + return provider ? (resolveProviderAlias(provider) ?? provider) : null; +} + function toNumber(value: unknown): number { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "string" && value.trim()) { @@ -345,40 +351,46 @@ export async function buildProviderHealthMatrix( const checkedAt = new Date(now).toISOString(); const range = normalizeRange(options.range); const cutoff = new Date(now - RANGE_MS[range]).toISOString(); - const providerFilter = toString(options.provider); + const providerFilter = canonicalProviderId(options.provider); const includeHealthy = options.includeHealthy !== false; - const [connections, breakers, lockouts, stats] = await Promise.all([ - getProviderConnections(providerFilter ? { provider: providerFilter } : {}), + // Connections use canonical ids while circuit breakers, lockouts, and historical + // call logs can retain the alias used at dispatch time. Normalize all sources here + // so a provider has one health row with every related signal attached. + const [connections, breakers, lockouts, rawStats] = await Promise.all([ + getProviderConnections({}), getAllCircuitBreakerStatuses(), getAllModelLockouts(), - Promise.resolve(queryCallLogTargetStats(cutoff, providerFilter)), + Promise.resolve(queryCallLogTargetStats(cutoff, null)), ]); const connectionRows = (connections as JsonRecord[]).filter((connection) => { - const provider = toString(connection.provider); + const provider = canonicalProviderId(connection.provider); return provider && (!providerFilter || provider === providerFilter); }); const breakerRows = (breakers as JsonRecord[]).filter((breaker) => { - const provider = toString(breaker.name); + const provider = canonicalProviderId(breaker.name); return provider && (!providerFilter || provider === providerFilter); }); const lockoutRows = (lockouts as JsonRecord[]).filter((lockout) => { - const provider = toString(lockout.provider); + const provider = canonicalProviderId(lockout.provider); return provider && (!providerFilter || provider === providerFilter); }); + const stats = rawStats + .map((row) => ({ ...row, provider: canonicalProviderId(row.provider) ?? row.provider })) + .filter((row) => !providerFilter || row.provider === providerFilter); const providerIds = new Set(); for (const connection of connectionRows) { - const provider = toString(connection.provider); + const provider = canonicalProviderId(connection.provider); if (provider) providerIds.add(provider); } for (const breaker of breakerRows) { - const provider = toString(breaker.name); + const provider = canonicalProviderId(breaker.name); if (provider) providerIds.add(provider); } for (const lockout of lockoutRows) { - const provider = toString(lockout.provider); + const provider = canonicalProviderId(lockout.provider); if (provider) providerIds.add(provider); } for (const row of stats) providerIds.add(row.provider); @@ -407,7 +419,7 @@ export async function buildProviderHealthMatrix( const lockoutsByTarget = new Map(); const lockoutCountByProvider = new Map(); for (const lockout of lockoutRows) { - const provider = toString(lockout.provider); + const provider = canonicalProviderId(lockout.provider); const connectionId = toString(lockout.connectionId); const model = toString(lockout.model); if (!provider || !model) continue; @@ -418,10 +430,12 @@ export async function buildProviderHealthMatrix( const providers: ProviderHealthMatrixProvider[] = []; for (const provider of [...providerIds].sort()) { const providerConnections = connectionRows.filter( - (connection) => toString(connection.provider) === provider + (connection) => canonicalProviderId(connection.provider) === provider ); const providerStats = stats.filter((row) => row.provider === provider); - const providerBreaker = breakerRows.find((breaker) => toString(breaker.name) === provider); + const providerBreaker = breakerRows.find( + (breaker) => canonicalProviderId(breaker.name) === provider + ); const circuitBreaker = providerBreaker ? { state: toString(providerBreaker.state) || "CLOSED", @@ -443,7 +457,7 @@ export async function buildProviderHealthMatrix( if (!accountRows.has(key)) accountRows.set(key, null); } for (const lockout of lockoutRows) { - if (toString(lockout.provider) !== provider) continue; + if (canonicalProviderId(lockout.provider) !== provider) continue; const key = accountKey(provider, toString(lockout.connectionId)); if (!accountRows.has(key)) accountRows.set(key, null); } @@ -466,7 +480,7 @@ export async function buildProviderHealthMatrix( modelIds.add(stat.model); } for (const lockout of lockoutRows) { - if (toString(lockout.provider) !== provider) continue; + if (canonicalProviderId(lockout.provider) !== provider) continue; if ((toString(lockout.connectionId) ?? "") !== (connectionId ?? "")) continue; const model = toString(lockout.model); if (model) modelIds.add(model); diff --git a/tests/unit/provider-health-matrix.test.ts b/tests/unit/provider-health-matrix.test.ts index 7ed151cd28..7e4edd12ab 100644 --- a/tests/unit/provider-health-matrix.test.ts +++ b/tests/unit/provider-health-matrix.test.ts @@ -22,6 +22,8 @@ const route = await import("../../src/app/api/providers/health-matrix/route.ts") const accountFallback = await import("@omniroute/open-sse/services/accountFallback"); const PROVIDER = "matrix-test-provider"; +const ALIAS_PROVIDER = "nous"; +const CANONICAL_ALIAS_PROVIDER = "nous-research"; async function resetStorage() { core.resetDbInstance(); @@ -33,6 +35,13 @@ async function resetStorage() { } } accountFallback.clearProviderFailure(PROVIDER); + accountFallback.clearProviderFailure(ALIAS_PROVIDER); + accountFallback.clearProviderFailure(CANONICAL_ALIAS_PROVIDER); + for (const lockout of accountFallback.getAllModelLockouts()) { + if (lockout.provider === ALIAS_PROVIDER || lockout.provider === CANONICAL_ALIAS_PROVIDER) { + accountFallback.clearModelLock(lockout.provider, lockout.connectionId, lockout.model); + } + } } async function enableManagementAuth() { @@ -139,6 +148,60 @@ test("provider health matrix combines connections, synced models, logs and locko assert.equal(locked.lockoutReason, "quota_exhausted"); }); +test("provider health matrix collapses alias-keyed signals into one canonical provider", async () => { + const connection = (await providersDb.createProviderConnection({ + id: "matrix-nous-connection", + provider: CANONICAL_ALIAS_PROVIDER, + authType: "apikey", + name: "nous-key", + apiKey: "test-key", + isActive: true, + })) as Record; + const connectionId = String(connection.id); + + accountFallback.lockModel( + ALIAS_PROVIDER, + connectionId, + "nous-locked-model", + "quota_exhausted", + 60_000, + {} + ); + accountFallback.recordProviderFailure(ALIAS_PROVIDER, undefined, undefined, { + failureThreshold: 1, + resetTimeoutMs: 60_000, + }); + + const report = await matrix.buildProviderHealthMatrix({ includeHealthy: true, range: "24h" }); + const canonicalRows = report.providers.filter( + (provider) => provider.provider === CANONICAL_ALIAS_PROVIDER + ); + + assert.equal(canonicalRows.length, 1, "the canonical provider must have exactly one health row"); + assert.equal( + report.providers.some((provider) => provider.provider === ALIAS_PROVIDER), + false, + "the alias must not create a duplicate provider row" + ); + + const provider = canonicalRows[0]; + assert.equal(provider.connections.total, 1); + assert.equal(provider.circuitBreaker?.state, "OPEN"); + assert.equal(provider.modelLockoutCount, 1); + assert.equal(provider.accounts[0]?.models[0]?.model, "nous-locked-model"); + assert.equal(provider.accounts[0]?.models[0]?.isLockedOut, true); + + const filteredByAlias = await matrix.buildProviderHealthMatrix({ + provider: ALIAS_PROVIDER, + includeHealthy: true, + range: "24h", + }); + assert.equal(filteredByAlias.providers.length, 1); + assert.equal(filteredByAlias.providers[0]?.provider, CANONICAL_ALIAS_PROVIDER); + assert.equal(filteredByAlias.providers[0]?.connections.total, 1); + assert.equal(filteredByAlias.providers[0]?.circuitBreaker?.state, "OPEN"); +}); + test("provider health matrix treats recovered models as degraded instead of error", async () => { const connection = (await providersDb.createProviderConnection({ id: "matrix-recovered-connection", diff --git a/tests/unit/serial/provider-health-autopilot.test.ts b/tests/unit/serial/provider-health-autopilot.test.ts index c7ea6c4fc5..67e0b22909 100644 --- a/tests/unit/serial/provider-health-autopilot.test.ts +++ b/tests/unit/serial/provider-health-autopilot.test.ts @@ -17,7 +17,8 @@ const core = await import("../../../src/lib/db/core.ts"); const settingsDb = await import("../../../src/lib/db/settings.ts"); const providersDb = await import("../../../src/lib/db/providers.ts"); const autopilot = await import("../../../src/lib/monitoring/providerHealthAutopilot.ts"); -const actionsRoute = await import("../../../src/app/api/providers/health-autopilot/actions/route.ts"); +const actionsRoute = + await import("../../../src/app/api/providers/health-autopilot/actions/route.ts"); const reportRoute = await import("../../../src/app/api/providers/health-autopilot/route.ts"); const routeGuard = await import("../../../src/server/authz/routeGuard.ts"); const authzPipeline = await import("../../../src/server/authz/pipeline.ts"); @@ -113,6 +114,59 @@ test("provider health autopilot reports actionable cooldown and model lockout is } }); +test("provider health autopilot canonicalizes alias-keyed signals while preserving raw breaker actions", async () => { + const canonicalProvider = "nous-research"; + const aliasProvider = "nous"; + const connection = await createCooldownConnection(canonicalProvider); + for (let failure = 0; failure < 20; failure += 1) { + accountFallback.recordProviderFailure(aliasProvider); + } + accountFallback.lockModel( + aliasProvider, + String(connection.id), + "alias-locked-model", + "quota", + 60_000, + {} + ); + + try { + const report = await autopilot.buildProviderHealthAutopilotReport({ + provider: aliasProvider, + includeHealthy: true, + }); + assert.equal(report.providers.length, 1); + const provider = report.providers[0]; + assert.equal(provider.provider, canonicalProvider); + assert.equal(provider.signals.connections.total, 1); + assert.equal(provider.signals.modelLockouts, 1); + + const clearBreaker = findAction(report, "clear_provider_breaker"); + assert.ok(clearBreaker); + assert.equal(clearBreaker.target.provider, aliasProvider); + + const applied = await autopilot.executeProviderHealthAutopilotAction({ + type: clearBreaker.type, + target: clearBreaker.target, + preconditionsHash: clearBreaker.preconditionsHash, + confirm: true, + }); + assert.equal(applied.status, 200); + + const afterReset = await autopilot.buildProviderHealthAutopilotReport({ + provider: aliasProvider, + includeHealthy: true, + }); + assert.equal( + afterReset.providers[0].issues.some((issue) => issue.kind === "provider_circuit_open"), + false + ); + } finally { + accountFallback.clearModelLock(aliasProvider, String(connection.id), "alias-locked-model"); + accountFallback.clearProviderFailure(aliasProvider); + } +}); + test("provider health autopilot action clears cooldown with stale-state protection", async () => { await enableManagementAuth(); const connection = await createCooldownConnection(); From 149049ca4ac945df2dfb719475d19fb66966b4b9 Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Sun, 16 Aug 2026 11:15:51 +0800 Subject: [PATCH 15/36] fix(db): default debugMode to false in getSettings() defaults (#10372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(db): default debugMode to false in getSettings() defaults Fresh installs (or installs missing the persisted debugMode key) ran in debug mode, contradicting the documented opt-in toggle and flooding new production installs with debug-level logs. Flip the default to false; installs that persisted debugMode=true keep it — only the missing-key path changes, no migration needed. Fixes #10312 * changelog: fragment for #10372 --- .../fixes/10372-debug-mode-default-false.md | 1 + src/lib/db/settings.ts | 4 +- ...-settings-debug-mode-default-10312.test.ts | 50 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/10372-debug-mode-default-false.md create mode 100644 tests/unit/db-settings-debug-mode-default-10312.test.ts diff --git a/changelog.d/fixes/10372-debug-mode-default-false.md b/changelog.d/fixes/10372-debug-mode-default-false.md new file mode 100644 index 0000000000..c1a59b4fb3 --- /dev/null +++ b/changelog.d/fixes/10372-debug-mode-default-false.md @@ -0,0 +1 @@ +- **fix(db):** `getSettings()` defaults `debugMode` to `false` — fresh installs no longer run in debug mode (persisted `debugMode: true` is preserved) ([#10372](https://github.com/diegosouzapw/OmniRoute/pull/10372) — thanks @lamchun1110) diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index c04c1fb75b..c8bf28ae45 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -212,7 +212,9 @@ export async function getSettings() { idempotencyWindowMs: 5000, wsAuth: false, maxBodySizeMb: requestBodyLimitMbFromEnv(process.env.MAX_BODY_SIZE_BYTES), - debugMode: true, + // #10312: opt-in only — a fresh install (or one missing the persisted key) + // must not run in debug mode; installs that persisted `true` keep it. + debugMode: false, // Opt-in diagnostic: when true, the chat handler emits a `log.debug("TOOLS", …)` // line per request summarizing tool count + MCP/hosted/client source breakdown. logToolSources: false, diff --git a/tests/unit/db-settings-debug-mode-default-10312.test.ts b/tests/unit/db-settings-debug-mode-default-10312.test.ts new file mode 100644 index 0000000000..327b4ce74d --- /dev/null +++ b/tests/unit/db-settings-debug-mode-default-10312.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-settings-debug-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const settings = await import("../../src/lib/db/settings.ts"); + +async function resetStorage() { + const globalDb = (globalThis as { __omnirouteDb?: { open: boolean; close(): void } }) + .__omnirouteDb; + try { + if (globalDb?.open) { + globalDb.close(); + } + } catch {} + delete (globalThis as { __omnirouteDb?: unknown }).__omnirouteDb; + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + core.getDbInstance(); +} + +await resetStorage(); + +test("#10312: empty store defaults debugMode to false (fresh install is not in debug)", async () => { + await resetStorage(); + const result = await settings.getSettings(); + assert.equal(result.debugMode, false); +}); + +test("#10312: persisted debugMode=true is preserved after the default flip", async () => { + await resetStorage(); + await settings.updateSettings({ debugMode: true }); + const result = await settings.getSettings(); + assert.equal(result.debugMode, true); +}); + +test("#10312: persisted debugMode=false stays false after the default flip", async () => { + await resetStorage(); + await settings.updateSettings({ debugMode: false }); + const result = await settings.getSettings(); + assert.equal(result.debugMode, false); +}); From 201c234b96be2eceb3657e7f19180d13a59f43bd Mon Sep 17 00:00:00 2001 From: azzaouiomar19-sketch Date: Sun, 16 Aug 2026 04:15:55 +0100 Subject: [PATCH 16/36] fix(chat): guard search providers from OpenAI fallback (#10394) Co-authored-by: DarkAngel <48388675+DarkEsteves@users.noreply.github.com> --- open-sse/executors/index.ts | 19 +++++++ .../unit/search-providers-chat-guard.test.ts | 55 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 tests/unit/search-providers-chat-guard.test.ts diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 87f2c93e8d..39c5880780 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -1,3 +1,4 @@ +import { SEARCH_PROVIDERS } from "../config/searchRegistry.ts"; import { AntigravityExecutor } from "./antigravity.ts"; import { GithubExecutor } from "./github.ts"; import { GheCopilotExecutor } from "./ghe-copilot.ts"; @@ -233,6 +234,17 @@ const defaultCache = new Map(); // follow-up once their own chat-routing behavior is confirmed. const CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS = new Set(["jules"]); +// #10274 — providers that exist ONLY as /v1/search endpoint entries +// (SEARCH_PROVIDERS in open-sse/config/searchRegistry.ts) and have no chat-completions +// REGISTRY entry anywhere in open-sse/. Without this guard, getExecutor() silently falls +// through to DefaultExecutor's `PROVIDERS[provider] || PROVIDERS.openai` fallback, sending +// the user's real search API key (e.g. a Tavily `tvly-...` key) to OpenAI's endpoint and +// surfacing OpenAI's own "Incorrect API key provided" error for a provider the user believes +// is the search provider. The set is DERIVED from SEARCH_PROVIDERS so adding a new search +// provider without updating this guard fails the regression test automatically. Search +// providers must be executed through /v1/search, never the chat-completions path. +const CHAT_UNSUPPORTED_SEARCH_PROVIDERS = new Set(Object.keys(SEARCH_PROVIDERS)); + export function getExecutor(provider) { if (executors[provider]) return executors[provider]; if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) { @@ -242,6 +254,13 @@ export function getExecutor(provider) { (err as Error & { status?: number }).status = 400; throw err; } + if (CHAT_UNSUPPORTED_SEARCH_PROVIDERS.has(provider)) { + const err = new Error( + `Provider "${provider}" is a search provider and does not support chat completions; use the /v1/search endpoint instead.` + ); + (err as Error & { status?: number }).status = 400; + throw err; + } if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider)); return defaultCache.get(provider); } diff --git a/tests/unit/search-providers-chat-guard.test.ts b/tests/unit/search-providers-chat-guard.test.ts new file mode 100644 index 0000000000..ac04fd5650 --- /dev/null +++ b/tests/unit/search-providers-chat-guard.test.ts @@ -0,0 +1,55 @@ +// Probe for issue #10274 -- "Search providers (tavily/exa/firecrawl) leak API keys to +// api.openai.com when used as chat/combo targets". +// +// Search-only providers (tavily-search, exa-search, firecrawl, serper-search, ...) exist +// ONLY in SEARCH_PROVIDERS (open-sse/config/searchRegistry.ts) + the /v1/search catalog; +// they have no chat REGISTRY entry and no specialized executor. Routing one of them as a +// chat-completions target (e.g. a round-robin combo with targets "tavily-search/web") +// therefore fell through to DefaultExecutor's `PROVIDERS[provider] || PROVIDERS.openai` +// fallback, which forwarded the user's real search API key to https://api.openai.com +// (observed as `[401] Incorrect API key provided: tvly-...` from OpenAI, not from Tavily). +// This probe proves the executor-level root cause directly and pins the guard: getExecutor() +// must throw a clear, sanitized 400 for every search provider instead of silently inheriting +// OpenAI's base URL/config. The guard set is DERIVED from SEARCH_PROVIDERS so adding a new +// search provider without updating the guard fails this test. +import test from "node:test"; +import assert from "node:assert/strict"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; +import { SEARCH_PROVIDERS } from "../../open-sse/config/searchRegistry.ts"; + +const SEARCH_PROVIDER_IDS = Object.keys(SEARCH_PROVIDERS); + +test("#10274: no search provider has a specialized chat executor", () => { + for (const id of SEARCH_PROVIDER_IDS) { + assert.equal( + hasSpecializedExecutor(id), + false, + `search provider '${id}' must not have a specialized chat executor` + ); + } +}); + +test("#10274: a chat-completion request routed to a search provider must not silently hit OpenAI's endpoint", () => { + // Desired behavior: search providers (registered only in SEARCH_PROVIDERS, never in the + // chat REGISTRY) must not silently resolve to OpenAI's chat/completions endpoint when + // routed through the normal chat-completions executor path. getExecutor() must throw a + // clear, sanitized error for this set instead of falling through to DefaultExecutor's + // `PROVIDERS.openai` fallback (which produced the "Incorrect API key provided: tvly-..." + // OpenAI error the reporter saw for genuine Tavily/Exa/Firecrawl keys). Before the fix, + // getExecutor("tavily-search") returned a working executor whose buildUrl() resolved to + // OpenAI's endpoint -- this assertion FAILS on unfixed release/v3.8.50 code because no + // error is thrown at all. + for (const id of SEARCH_PROVIDER_IDS) { + assert.throws( + () => getExecutor(id), + (err) => { + assert.match(err.message, /search provider/i); + assert.match(err.message, /does not support chat completions/i); + assert.match(err.message, /\/v1\/search/i); + assert.equal(err.status, 400); + return true; + }, + `search provider '${id}' must raise a clear error instead of inheriting OpenAI's base URL/config` + ); + } +}); From 6e97fbf3406e703c7cf34211d0c9586751b4f3cc Mon Sep 17 00:00:00 2001 From: Jacky Lam Date: Sun, 16 Aug 2026 11:16:00 +0800 Subject: [PATCH 17/36] fix(sse): dedupe header-budget drop warns by drop-set fingerprint (#10397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sse): dedupe header-budget drop warns by drop-set fingerprint The 768-byte forwarded-header budget drop path emitted a full warn (with up to 20 dropped entries) on every SSE response whose headers exceeded the budget. The dropped set is usually identical across responses from the same upstream, so the repeats carried no new information — under Desktop multi-stream use this buried real errors and added event-loop serialization work. Warn once per unique drop fingerprint (sorted dropped-header names, capped at 1000 fingerprints) per process, then log at debug level. Fixes #10315 * changelog: fragment for #10397 --- .../fixes/10397-header-budget-warn-dedupe.md | 1 + open-sse/handlers/chatCore/responseHeaders.ts | 50 ++++++++- ...core-header-drop-warn-dedupe-10315.test.ts | 102 ++++++++++++++++++ 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 changelog.d/fixes/10397-header-budget-warn-dedupe.md create mode 100644 tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts diff --git a/changelog.d/fixes/10397-header-budget-warn-dedupe.md b/changelog.d/fixes/10397-header-budget-warn-dedupe.md new file mode 100644 index 0000000000..d4d117b913 --- /dev/null +++ b/changelog.d/fixes/10397-header-budget-warn-dedupe.md @@ -0,0 +1 @@ +- **fix(sse):** the header-budget drop warning fires once per unique dropped-header set instead of on every SSE response (warn-storm fix) ([#10397](https://github.com/diegosouzapw/OmniRoute/pull/10397) — thanks @lamchun1110) diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts index 43fdc5a88e..1a6602f506 100644 --- a/open-sse/handlers/chatCore/responseHeaders.ts +++ b/open-sse/handlers/chatCore/responseHeaders.ts @@ -40,7 +40,10 @@ const DEFAULT_FORWARDED_HEADER_BUDGET_BYTES = 768; * module-cache manipulation. */ export function resolveForwardedHeaderBudget(env?: string): number { - const parsed = Number.parseInt(String(env ?? process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES), 10); + const parsed = Number.parseInt( + String(env ?? process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES), + 10 + ); return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_FORWARDED_HEADER_BUDGET_BYTES; } @@ -56,8 +59,31 @@ const responseHeaderEncoder = new TextEncoder(); type ResponseHeaderLogger = { warn?: (tag: string, message: string, data?: Record) => void; + debug?: (tag: string, message: string, data?: Record) => void; } | null; +/** + * #10315: the dropped-header set is usually identical across responses from the + * same upstream, so warn once per unique drop fingerprint per process, then log + * at debug level — a per-SSE-response warn storm buries real errors and adds + * event-loop serialization work. Fingerprints are dropped-header-name sets, so + * the set stays bounded by the distinct upstream header shapes in practice. + */ +const DROPPED_HEADER_WARN_FINGERPRINT_LIMIT = 1000; +const droppedHeaderWarnFingerprints = new Set(); + +export function fingerprintDroppedHeaders(dropped: Array<{ name: string; bytes: number }>): string { + return dropped + .map((header) => header.name.toLowerCase()) + .sort() + .join(","); +} + +/** Test hook: forget already-warned drop fingerprints. */ +export function resetDroppedHeaderWarnFingerprints(): void { + droppedHeaderWarnFingerprints.clear(); +} + function responseHeaderWireBytes(name: string, value: string): number { return responseHeaderEncoder.encode(`${name}: ${value}\r\n`).byteLength; } @@ -182,12 +208,30 @@ export function buildStreamingResponseHeaders( } if (droppedHeaders.length > 0) { - log?.warn?.("HTTP", "Dropped upstream response headers that exceeded forwarding budget", { + const dropPayload = { budgetBytes: MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES, forwardedBytes, droppedCount: droppedHeaders.length, droppedHeaders: droppedHeaders.slice(0, MAX_LOGGED_DROPPED_RESPONSE_HEADERS), - }); + }; + const fingerprint = fingerprintDroppedHeaders(droppedHeaders); + if (droppedHeaderWarnFingerprints.has(fingerprint)) { + log?.debug?.( + "HTTP", + "Dropped upstream response headers that exceeded forwarding budget (already warned once for this drop set)", + dropPayload + ); + } else { + if (droppedHeaderWarnFingerprints.size >= DROPPED_HEADER_WARN_FINGERPRINT_LIMIT) { + droppedHeaderWarnFingerprints.clear(); + } + droppedHeaderWarnFingerprints.add(fingerprint); + log?.warn?.( + "HTTP", + "Dropped upstream response headers that exceeded forwarding budget", + dropPayload + ); + } } const responseHeaders: Record = { diff --git a/tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts b/tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts new file mode 100644 index 0000000000..8485024b1e --- /dev/null +++ b/tests/unit/chatcore-header-drop-warn-dedupe-10315.test.ts @@ -0,0 +1,102 @@ +// #10315: the header-budget drop warn must not storm — identical dropped-header +// sets recur on every SSE response from the same upstream, so we warn once per +// unique drop fingerprint per process and fall back to debug afterwards. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { + buildStreamingResponseHeaders, + fingerprintDroppedHeaders, + resetDroppedHeaderWarnFingerprints, +} = await import("../../open-sse/handlers/chatCore/responseHeaders.ts"); + +type DropPayload = { + budgetBytes: number; + forwardedBytes: number; + droppedCount: number; + droppedHeaders: Array<{ name: string; bytes: number }>; +}; + +function makeLogger() { + const warns: DropPayload[] = []; + const debugs: DropPayload[] = []; + return { + logger: { + warn: (_tag: string, _msg: string, data?: DropPayload) => warns.push(data as DropPayload), + debug: (_tag: string, _msg: string, data?: DropPayload) => debugs.push(data as DropPayload), + }, + warns, + debugs, + }; +} + +const meta = {} as Parameters[1]; + +// Small header + two ~600-byte headers: the first big one fits the 768-byte +// budget alongside the small one, the second is always dropped. +function oversizedProviderHeaders(): Headers { + return new Headers({ + "x-kept-small": "k".repeat(10), + "x-drop-alpha": "a".repeat(600), + "x-drop-beta": "b".repeat(600), + }); +} + +test("#10315: 100 identical oversized responses emit exactly one warn, the rest at debug", () => { + resetDroppedHeaderWarnFingerprints(); + const { logger, warns, debugs } = makeLogger(); + for (let i = 0; i < 100; i++) { + buildStreamingResponseHeaders(oversizedProviderHeaders(), meta, logger); + } + assert.equal(warns.length, 1); + assert.equal(debugs.length, 99); + assert.equal(warns[0].droppedCount, 1); +}); + +test("#10315: a different drop set warns again", () => { + resetDroppedHeaderWarnFingerprints(); + const { logger, warns } = makeLogger(); + buildStreamingResponseHeaders(oversizedProviderHeaders(), meta, logger); + assert.equal(warns.length, 1); + buildStreamingResponseHeaders( + new Headers({ + "x-kept-small": "k".repeat(10), + "x-drop-gamma": "g".repeat(600), + "x-drop-delta": "d".repeat(600), + }), + meta, + logger + ); + assert.equal(warns.length, 2); +}); + +test("#10315: fingerprint is order-insensitive to dropped header names", () => { + assert.equal( + fingerprintDroppedHeaders([ + { name: "X-Drop-Beta", bytes: 600 }, + { name: "x-drop-alpha", bytes: 600 }, + ]), + fingerprintDroppedHeaders([ + { name: "x-drop-alpha", bytes: 600 }, + { name: "X-Drop-Beta", bytes: 600 }, + ]) + ); +}); + +test("#10315: reset hook forgets fingerprints so the same drop set warns again", () => { + resetDroppedHeaderWarnFingerprints(); + const { logger, warns } = makeLogger(); + buildStreamingResponseHeaders(oversizedProviderHeaders(), meta, logger); + resetDroppedHeaderWarnFingerprints(); + buildStreamingResponseHeaders(oversizedProviderHeaders(), meta, logger); + assert.equal(warns.length, 2); +}); + +test("#10315: responses within budget never warn", () => { + resetDroppedHeaderWarnFingerprints(); + const { logger, warns, debugs } = makeLogger(); + const headers = buildStreamingResponseHeaders(new Headers({ "x-fits": "ok" }), meta, logger); + assert.equal(headers["x-fits"], "ok"); + assert.equal(warns.length, 0); + assert.equal(debugs.length, 0); +}); From 94cf4c402a1684804469fdca864dcc7be83cad62 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:16:04 +0200 Subject: [PATCH 18/36] fix(executors): rotate to the next account on network throws when the account has a dedicated proxy (#10402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpencodeExecutor and MimocodeExecutor rotated to the next account only on HTTP 429. A network exception (timeout, connection refused/reset) on one account instead propagated out of execute() and failed the whole request, even when other accounts remained available. Both executors now rotate on a network exception only when the failed account has its own dedicated proxy (account.proxy !== null) — a dead proxy is genuinely account-scoped, so rotating away from it is safe. Accounts sharing the default egress (no proxy configured) trigger the same cooldown and are skipped for the rest of the request once the shared egress is known down, but a later account with its own dedicated proxy is still tried normally — a throw on a proxy-less account no longer strands a proxied account further in the rotation. This behavior is gated behind NETWORK_ROTATION_SHARED_EGRESS_GUARD (Feature Flag, default on); disabled, it reproduces the immediate-propagation behavior this fix started from. The shared rotation mechanics (pickAccount/markCooldown/markSuccess) are extracted into executors/accountRotation.ts, used by both executors — they had independently implemented the same round-robin+cooldown skeleton. This also fixes an identical, pre-existing bug in MimocodeExecutor that predates this PR: its catch block called markCooldown unconditionally on any throw, with no proxy check and no warn log (a silent exception swallow on a path that influences the result). The cooldown formula for both the proxy and shared-egress cases reuses the repo's already-established "transient, not clearly attributable" constants (errorConfig.ts TRANSIENT_COOLDOWN_MS/COOLDOWN_MS.transientMax, already used by accountFallback.ts for network-error classification) instead of introducing a separate value. MimocodeExecutor's network-error 502 body also now goes through buildErrorBody()/sanitizeErrorMessage() instead of embedding the raw caught error message directly (Hard Rule #12), matching the sanitization already used on its #2101 malformed-request path. Validated by TDD (Hard Rule #18): tests/unit/account-rotation.test.ts covers the shared module directly; opencode-proxy-rotation-4954.test.ts and mimocode-executor.test.ts cover the proxy-configured rotation path, the mixed-fleet case, the shared-egress single-network-call case, and the NETWORK_ROTATION_SHARED_EGRESS_GUARD-disabled legacy path, for each executor. tsc, lint, and the provider golden-path gates (check:provider-consistency, check:provider-assets, provider-translate-path-golden.test.ts) are clean on all touched files. Co-authored-by: Max --- .../10393-opencode-rotate-network-throw.md | 1 + open-sse/executors/accountRotation.ts | 109 ++++++++ open-sse/executors/mimocode.ts | 122 ++++++--- open-sse/executors/opencode.ts | 148 ++++++---- .../constants/featureFlagDefinitions.ts | 12 + src/shared/utils/featureFlags.ts | 12 + tests/unit/account-rotation.test.ts | 116 ++++++++ tests/unit/feature-flags-settings.test.ts | 16 +- tests/unit/mimocode-executor.test.ts | 252 +++++++++++++++++- .../unit/opencode-proxy-rotation-4954.test.ts | 233 +++++++++++++++- 10 files changed, 918 insertions(+), 103 deletions(-) create mode 100644 changelog.d/fixes/10393-opencode-rotate-network-throw.md create mode 100644 open-sse/executors/accountRotation.ts create mode 100644 tests/unit/account-rotation.test.ts diff --git a/changelog.d/fixes/10393-opencode-rotate-network-throw.md b/changelog.d/fixes/10393-opencode-rotate-network-throw.md new file mode 100644 index 0000000000..b0d8e9fb1e --- /dev/null +++ b/changelog.d/fixes/10393-opencode-rotate-network-throw.md @@ -0,0 +1 @@ +- **fix(executors):** OpencodeExecutor and MimocodeExecutor now rotate to the next account on network exceptions (timeout, connection refused/reset) when the failed account has a dedicated proxy, not only on 429 — a throw on one account no longer fails the whole request when other accounts remain. Accounts sharing the default egress (no proxy) fail fast instead of retrying the same outage against every account. The shared rotation mechanics (`pickAccount`/`markCooldown`/`markSuccess`) are now extracted into `accountRotation.ts`, fixing an identical unconditional-cooldown gap that pre-dated this PR in MimocodeExecutor ([#10393](https://github.com/diegosouzapw/OmniRoute/pull/10393)) diff --git a/open-sse/executors/accountRotation.ts b/open-sse/executors/accountRotation.ts new file mode 100644 index 0000000000..b5f25afc66 --- /dev/null +++ b/open-sse/executors/accountRotation.ts @@ -0,0 +1,109 @@ +/** + * Shared multi-account rotation mechanics for noauth executors that round-robin + * across several "accounts" (fingerprints), each with an optional dedicated + * proxy — currently `OpencodeExecutor` and `MimocodeExecutor`. + * + * Extracted after both executors independently implemented the same + * pickAccount/markCooldown/markSuccess skeleton with the same exponential + * backoff, and independently needed the same fix for the same latent bug (a + * network exception was treated as account-scoped rotation fodder even for + * accounts sharing the default egress — see `isNetworkErrorRotatable`). + */ + +// Reuses the repo's established "transient, not clearly attributable" failure +// cooldown (already used by accountFallback.ts for network-error dedup, see +// its "one transient blip opens the whole-provider breaker" comment) instead +// of inventing a separate constant — same magnitude the codebase already +// applies whether the failure is a 429 or a network-level throw. +import { TRANSIENT_COOLDOWN_MS, COOLDOWN_MS } from "../config/errorConfig.ts"; + +/** Per-account proxy configuration, persisted by NoAuthAccountCard under + * `providerSpecificData.accountProxies` (keyed by the account id, which the UI + * stores in `providerSpecificData.fingerprints`). */ +export interface AccountProxyConfig { + fingerprint: string; + proxy: { + type: string; + host: string; + port: number; + username?: string; + password?: string; + relayAuth?: string; + } | null; +} + +/** The subset of per-account state the rotation mechanics need. Executors may + * carry additional fields (e.g. mimocode's `jwt`/`expiresAt`) — this is the + * minimum shape `pickAccount`/`markCooldown`/`markSuccess` operate on. */ +export interface RotatableAccount { + fingerprint: string; + cooldownUntil: number; + consecutiveFails: number; + proxy: AccountProxyConfig["proxy"]; +} + +const COOLDOWN_BASE_MS = TRANSIENT_COOLDOWN_MS; +const COOLDOWN_MAX_MS = COOLDOWN_MS.transientMax; + +export function isAccountReady(account: RotatableAccount): boolean { + return account.cooldownUntil <= Date.now(); +} + +/** Round-robin pick, skipping accounts not `isReady`; falls back to the next + * index (even if not ready) so a caller always gets an account rather than + * hanging when every account is unavailable. Mutates `state.nextAccountIdx`. + * + * `isReady` defaults to the plain cooldown check (`isAccountReady`); pass a + * custom predicate when readiness depends on more than cooldown (e.g. + * mimocode's JWT-freshness-aware variant). */ +export function pickAccount( + accounts: T[], + state: { nextAccountIdx: number }, + isReady: (account: T) => boolean = isAccountReady +): T { + for (let i = 0; i < accounts.length; i++) { + const idx = (state.nextAccountIdx + i) % accounts.length; + const acct = accounts[idx]; + if (isReady(acct)) { + state.nextAccountIdx = (idx + 1) % accounts.length; + return acct; + } + } + const fallbackIdx = state.nextAccountIdx % accounts.length; + state.nextAccountIdx = (state.nextAccountIdx + 1) % accounts.length; + return accounts[fallbackIdx]; +} + +export function markCooldown(account: RotatableAccount): void { + account.consecutiveFails++; + const backoff = Math.min( + COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1), + COOLDOWN_MAX_MS + ); + account.cooldownUntil = Date.now() + backoff + Math.random() * 1000; +} + +export function markSuccess(account: RotatableAccount): void { + account.consecutiveFails = 0; +} + +/** Mask an account id for logs (UI calls it a fingerprint). */ +export function maskAccountId(fingerprint: string): string { + if (!fingerprint) return "direct"; + return `${fingerprint.slice(0, 8)}…`; +} + +/** + * Whether a network exception (timeout, connection refused/reset) on this + * account should trigger rotation to the next account, vs propagating. + * + * Only true when the account has its own egress (a configured proxy) — that's + * the case a dead/unreachable proxy genuinely justifies rotating away from. + * Accounts sharing the default egress (no proxy) can all fail at once on a + * real network outage: rotating there would just retry the same failure + * against every account while poisoning each one's cooldown for a cause that + * isn't theirs. + */ +export function isNetworkErrorRotatable(account: RotatableAccount): boolean { + return account.proxy !== null; +} diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts index 356963a581..9ee27e0afc 100644 --- a/open-sse/executors/mimocode.ts +++ b/open-sse/executors/mimocode.ts @@ -27,13 +27,21 @@ import { createProxyDispatcher } from "../utils/proxyDispatcher.ts"; import { RATE_LIMIT_TEXT_PATTERNS } from "../services/accountFallback.ts"; import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts"; import { fetch as undiciFetch, type Dispatcher } from "undici"; +import { + type AccountProxyConfig as SharedAccountProxyConfig, + type RotatableAccount, + pickAccount as pickRotatableAccount, + markCooldown as markAccountCooldown, + markSuccess as markAccountSuccess, + maskAccountId, + isNetworkErrorRotatable, +} from "./accountRotation.ts"; +import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags"; const BOOTSTRAP_PATH = "/api/free-ai/bootstrap"; const CHAT_PATH = "/api/free-ai/openai/chat"; const JWT_REFRESH_BUFFER_MS = 5 * 60 * 1000; const BOOTSTRAP_TIMEOUT_MS = 15_000; -const COOLDOWN_BASE_MS = 5_000; -const COOLDOWN_MAX_MS = 60_000; const MIMO_SOURCE = "mimocode-cli-free"; @@ -82,24 +90,12 @@ const USER_AGENTS = [ // ── Account State ────────────────────────────────────────────────────────── /** Per-account proxy configuration, passed through providerSpecificData.accountProxies. */ -export interface AccountProxyConfig { - fingerprint: string; - proxy: { - type: string; - host: string; - port: number; - username?: string; - password?: string; - relayAuth?: string; - } | null; -} +export type AccountProxyConfig = SharedAccountProxyConfig; -interface AccountState { +interface AccountState extends RotatableAccount { fingerprint: string; jwt: string; expiresAt: number; - cooldownUntil: number; - consecutiveFails: number; /** * #3837/#5521: the account's resolved proxy, or `null` when none is configured. * Always present (never `undefined`) so callers can read `acct.proxy` directly — @@ -223,7 +219,10 @@ function rewriteModelName(model: string): string { export class MimocodeExecutor extends BaseExecutor { private accounts: AccountState[] = []; - private nextAccountIdx = 0; + // Not `private`: passed as the mutable rotation cursor to the shared + // pickAccount() helper, which needs a plain `{ nextAccountIdx }` shape — + // TS's private-member nominal check rejects `this` there otherwise. + nextAccountIdx = 0; private baseUrl: string; private proxyUrlMap = new Map(); private static encoder = new TextEncoder(); @@ -342,30 +341,15 @@ export class MimocodeExecutor extends BaseExecutor { } private pickAccount(): AccountState { - for (let i = 0; i < this.accounts.length; i++) { - const idx = (this.nextAccountIdx + i) % this.accounts.length; - const acct = this.accounts[idx]; - if (isAccountReady(acct)) { - this.nextAccountIdx = (idx + 1) % this.accounts.length; - return acct; - } - } - const fallbackIdx = this.nextAccountIdx % this.accounts.length; - this.nextAccountIdx = (this.nextAccountIdx + 1) % this.accounts.length; - return this.accounts[fallbackIdx]; + return pickRotatableAccount(this.accounts, this, isAccountReady); } private markCooldown(account: AccountState): void { - account.consecutiveFails++; - const backoff = Math.min( - COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1), - COOLDOWN_MAX_MS - ); - account.cooldownUntil = Date.now() + backoff + Math.random() * 1000; + markAccountCooldown(account); } private markSuccess(account: AccountState): void { - account.consecutiveFails = 0; + markAccountSuccess(account); } /** @@ -592,9 +576,25 @@ export class MimocodeExecutor extends BaseExecutor { this.syncAccountsFromCredentials(input.credentials); + const sharedEgressGuardEnabled = isNetworkRotationSharedEgressGuardEnabled(); + // Set once a proxy-less account's network throw reveals the shared egress + // is down — subsequent proxy-less accounts this request are skipped + // without a network call, but proxied accounts (independent egress) are + // still tried normally. See NETWORK_ROTATION_SHARED_EGRESS_GUARD. + let sharedEgressDown = false; + // Try each account, skip cooldown ones for (let attempt = 0; attempt < this.accounts.length; attempt++) { const account = this.pickAccount(); + + if (sharedEgressGuardEnabled && sharedEgressDown && !account.proxy) { + log?.warn?.( + "MIMOCODE", + `skipping account ${maskAccountId(account.fingerprint)} (no dedicated proxy, shared egress already down this request)` + ); + continue; + } + try { const headers = this.buildHeaders(input.credentials, stream); const resp = await this.fetchWithAuthRetry(url, headers, reqBody, signal, account, log); @@ -623,16 +623,60 @@ export class MimocodeExecutor extends BaseExecutor { transformedBody: reqBody, }; } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const masked = maskAccountId(account.fingerprint); + + // Mirrors OpencodeExecutor's rotation guard: a network exception is only account-scoped + // when this account has its OWN egress (a configured proxy). Without + // one, accounts share the default egress — the failure isn't + // attributable to this account, and trying the next one would just + // retry the same outage while poisoning its cooldown for a cause + // that isn't theirs. Fail fast instead of exhausting every account. + if (!isNetworkErrorRotatable(account)) { + if (sharedEgressGuardEnabled) { + this.markCooldown(account); + sharedEgressDown = true; + log?.warn?.( + "MIMOCODE", + `network error on account ${masked} (no dedicated proxy, shared egress), cooldown applied — trying next available account… (${msg})` + ); + continue; + } + log?.warn?.( + "MIMOCODE", + `network error on account ${masked} (no dedicated proxy, shared egress) — not rotating (${msg})` + ); + return { + response: new Response( + encoder.encode( + JSON.stringify( + buildErrorBody(502, msg, undefined, { + type: "upstream_error", + code: "EXECUTOR_ERROR", + }) + ) + ), + { status: 502, headers: { "Content-Type": "application/json" } } + ), + url, + headers: this.buildHeaders(input.credentials, stream), + transformedBody: body, + }; + } + this.markCooldown(account); + log?.warn?.("MIMOCODE", `network error on account ${masked}, rotating to next… (${msg})`); if (attempt === this.accounts.length - 1) { - const msg = err instanceof Error ? err.message : String(err); log?.error?.("MIMOCODE", `Executor error: ${msg}`); return { response: new Response( encoder.encode( - JSON.stringify({ - error: { message: msg, type: "upstream_error", code: "EXECUTOR_ERROR" }, - }) + JSON.stringify( + buildErrorBody(502, msg, undefined, { + type: "upstream_error", + code: "EXECUTOR_ERROR", + }) + ) ), { status: 502, headers: { "Content-Type": "application/json" } } ), diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index dddcbe4900..26be70bf2c 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -7,37 +7,30 @@ import { } from "../utils/reasoningContentInjector.ts"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts"; +import { + type AccountProxyConfig, + type RotatableAccount, + pickAccount as pickRotatableAccount, + markCooldown as markAccountCooldown, + markSuccess as markAccountSuccess, + maskAccountId, + isNetworkErrorRotatable, +} from "./accountRotation.ts"; +import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags"; /** * Per-account proxy configuration, persisted by NoAuthAccountCard under * `providerSpecificData.accountProxies` (keyed by the account id, which the UI * stores in `providerSpecificData.fingerprints`). Same shape mimocode uses. */ -export interface OpencodeAccountProxyConfig { - fingerprint: string; - proxy: { - type: string; - host: string; - port: number; - username?: string; - password?: string; - relayAuth?: string; - } | null; -} +export type OpencodeAccountProxyConfig = AccountProxyConfig; /** Runtime rotation/cooldown state for one "OpenCode Free" account. */ -interface OpencodeAccountState { +interface OpencodeAccountState extends RotatableAccount { /** Account id (UI: providerSpecificData.fingerprints[i]); "" for the default direct account. */ fingerprint: string; - cooldownUntil: number; - consecutiveFails: number; - /** Resolved proxy config for this account (null = direct egress). */ - proxy: OpencodeAccountProxyConfig["proxy"]; } -const OPENCODE_COOLDOWN_BASE_MS = 5_000; -const OPENCODE_COOLDOWN_MAX_MS = 60_000; - const EFFORT_LEVELS = ["low", "medium", "high", "max"] as const; /** @@ -147,7 +140,10 @@ export class OpencodeExecutor extends BaseExecutor { private accounts: OpencodeAccountState[] = [ { fingerprint: "", cooldownUntil: 0, consecutiveFails: 0, proxy: null }, ]; - private nextAccountIdx = 0; + // Not `private`: passed as the mutable rotation cursor to the shared + // pickAccount() helper, which needs a plain `{ nextAccountIdx }` shape — + // TS's private-member nominal check rejects `this` there otherwise. + nextAccountIdx = 0; constructor(provider: string) { super(provider, PROVIDERS[provider] || PROVIDERS.openai); @@ -190,42 +186,17 @@ export class OpencodeExecutor extends BaseExecutor { if (this.nextAccountIdx >= this.accounts.length) this.nextAccountIdx = 0; } - private isAccountReady(account: OpencodeAccountState): boolean { - return account.cooldownUntil <= Date.now(); - } - /** Round-robin pick, skipping accounts in cooldown; falls back to the next index. */ private pickAccount(): OpencodeAccountState { - for (let i = 0; i < this.accounts.length; i++) { - const idx = (this.nextAccountIdx + i) % this.accounts.length; - const acct = this.accounts[idx]; - if (this.isAccountReady(acct)) { - this.nextAccountIdx = (idx + 1) % this.accounts.length; - return acct; - } - } - const fallbackIdx = this.nextAccountIdx % this.accounts.length; - this.nextAccountIdx = (this.nextAccountIdx + 1) % this.accounts.length; - return this.accounts[fallbackIdx]; + return pickRotatableAccount(this.accounts, this); } private markCooldown(account: OpencodeAccountState): void { - account.consecutiveFails++; - const backoff = Math.min( - OPENCODE_COOLDOWN_BASE_MS * Math.pow(2, account.consecutiveFails - 1), - OPENCODE_COOLDOWN_MAX_MS - ); - account.cooldownUntil = Date.now() + backoff + Math.random() * 1000; + markAccountCooldown(account); } private markSuccess(account: OpencodeAccountState): void { - account.consecutiveFails = 0; - } - - /** Mask an account id for logs (UI calls it a fingerprint). */ - private static maskAccountId(fingerprint: string): string { - if (!fingerprint) return "direct"; - return `${fingerprint.slice(0, 8)}…`; + markAccountSuccess(account); } async execute(input: ExecuteInput) { @@ -267,11 +238,35 @@ export class OpencodeExecutor extends BaseExecutor { } const { log } = input; - let lastResult: Awaited> | null = null; + // This loop only ever dispatches through super.execute() (the HTTP request + // path), which always resolves the object-shaped arm of ExecutorExecuteResult + // — the bare-Response arm belongs to web/scraping executors only (base.ts:290). + type HttpExecuteResult = Extract< + Awaited>, + { response: Response } + >; + let lastResult: HttpExecuteResult | null = null; + let lastSharedEgressError: unknown = null; + const sharedEgressGuardEnabled = isNetworkRotationSharedEgressGuardEnabled(); + // Set once a proxy-less account's network throw reveals the shared + // egress is down (see NETWORK_ROTATION_SHARED_EGRESS_GUARD below) — + // subsequent proxy-less accounts this request are skipped without a + // network call, but proxied accounts (independent egress) are still + // tried normally. + let sharedEgressDown = false; for (let attempt = 0; attempt < this.accounts.length; attempt++) { const account = this.pickAccount(); - const masked = OpencodeExecutor.maskAccountId(account.fingerprint); + const masked = maskAccountId(account.fingerprint); + + if (sharedEgressGuardEnabled && sharedEgressDown && !account.proxy) { + log?.warn?.( + "OPENCODE", + `skipping account ${masked} (no dedicated proxy, shared egress already down this request)` + ); + continue; + } + // #5217 (Gap 2): promoted debug→info so the per-request account/proxy // rotation selection is visible in the Console log view at the default // APP_LOG_LEVEL=info (users could not see which account/proxy was used). @@ -287,9 +282,46 @@ export class OpencodeExecutor extends BaseExecutor { // Pin egress to this account's proxy for the whole BaseExecutor dispatch // (incl. its intra-URL 429 retries). skipUpstreamRetry lets THIS loop own // the cross-account 429 fallback instead of BaseExecutor's same-key retry. - const result = await runWithProxyContext(account.proxy, () => - super.execute({ ...input, skipUpstreamRetry: true }) - ); + let result: HttpExecuteResult; + try { + // super.execute() here always dispatches the HTTP path (opencode is an + // OpenAI-compatible API, never the web/scraping bare-Response arm) — + // see base.ts:290-294. + result = (await runWithProxyContext(account.proxy, () => + super.execute({ ...input, skipUpstreamRetry: true }) + )) as HttpExecuteResult; + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + // A network exception (timeout, connection refused/reset) is only + // account-scoped when this account has its OWN egress (a configured + // proxy) — that's the case a dead/unreachable proxy justifies rotating + // away from. Without a proxy, accounts share the same network egress: + // the failure isn't attributable to this account. Never swallowed + // silently either way: logged before rotating, skipping, or rethrowing. + if (!isNetworkErrorRotatable(account)) { + if (sharedEgressGuardEnabled) { + this.markCooldown(account); + sharedEgressDown = true; + lastSharedEgressError = err; + log?.warn?.( + "OPENCODE", + `network error on account ${masked} (no dedicated proxy, shared egress), cooldown applied — trying next available account… (${reason})` + ); + continue; + } + log?.warn?.( + "OPENCODE", + `network error on account ${masked} (no dedicated proxy, shared egress) — not rotating (${reason})` + ); + throw err; + } + this.markCooldown(account); + log?.warn?.( + "OPENCODE", + `network error on account ${masked}, rotating to next… (${reason})` + ); + continue; + } lastResult = result; const status = result.response.status; @@ -303,6 +335,16 @@ export class OpencodeExecutor extends BaseExecutor { return result; } + // The loop exhausted without a result. If it's because every remaining + // proxy-less account was skipped once the shared egress was known down + // (rather than actually tried), propagate that original throw — an + // extra direct call here would just be a second doomed attempt against + // the same dead path, which is exactly the latency this guard exists + // to avoid (see NETWORK_ROTATION_SHARED_EGRESS_GUARD). + if (sharedEgressDown && !lastResult && lastSharedEgressError !== null) { + throw lastSharedEgressError; + } + // All accounts returned 429 (or errored) — surface the last response. return lastResult ?? (await super.execute(input)); } finally { diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 4e0a8a4477..ed7e74c652 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -164,6 +164,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "danger", }, + { + key: "NETWORK_ROTATION_SHARED_EGRESS_GUARD", + label: "Network Rotation Shared-Egress Guard", + description: + "On a network exception (timeout, connection refused/reset) for a multi-account rotation executor, when the failing account has no dedicated proxy, apply a short cooldown and skip other proxy-less accounts for the rest of the request instead of retrying each one. On by default (safe: no egress IP change, only reduces latency/cooldown risk on shared-egress accounts). Disable to restore immediate propagation on the first proxy-less throw.", + descriptionI18nKey: "featureFlagNetworkRotationSharedEgressGuardDescription", + category: "network", + defaultValue: "true", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, { key: "MITM_DISABLE_TLS_VERIFY", label: "Disable TLS Verify (MITM)", diff --git a/src/shared/utils/featureFlags.ts b/src/shared/utils/featureFlags.ts index 7568e982fd..9a3582370d 100644 --- a/src/shared/utils/featureFlags.ts +++ b/src/shared/utils/featureFlags.ts @@ -111,3 +111,15 @@ export function isControlPlaneProxyDirectFallbackEnabled(): boolean { return false; } } + +export function isNetworkRotationSharedEgressGuardEnabled(): boolean { + try { + return isFeatureFlagEnabled("NETWORK_ROTATION_SHARED_EGRESS_GUARD"); + } catch (error) { + console.error( + "[featureFlags] Failed to resolve NETWORK_ROTATION_SHARED_EGRESS_GUARD, defaulting to enabled:", + error instanceof Error ? error.message : error + ); + return true; + } +} diff --git a/tests/unit/account-rotation.test.ts b/tests/unit/account-rotation.test.ts new file mode 100644 index 0000000000..f4864ee2dd --- /dev/null +++ b/tests/unit/account-rotation.test.ts @@ -0,0 +1,116 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + isAccountReady, + pickAccount, + markCooldown, + markSuccess, + maskAccountId, + isNetworkErrorRotatable, + type RotatableAccount, +} from "../../open-sse/executors/accountRotation.ts"; + +function account(overrides: Partial = {}): RotatableAccount { + return { + fingerprint: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + cooldownUntil: 0, + consecutiveFails: 0, + proxy: null, + ...overrides, + }; +} + +describe("accountRotation", () => { + it("isAccountReady is true when cooldownUntil is in the past", () => { + assert.strictEqual(isAccountReady(account({ cooldownUntil: Date.now() - 1000 })), true); + }); + + it("isAccountReady is false when cooldownUntil is in the future", () => { + assert.strictEqual(isAccountReady(account({ cooldownUntil: Date.now() + 60_000 })), false); + }); + + it("markCooldown increments consecutiveFails and sets a future cooldownUntil", () => { + const acct = account(); + markCooldown(acct); + assert.strictEqual(acct.consecutiveFails, 1); + assert.ok(acct.cooldownUntil > Date.now()); + }); + + it("markCooldown backs off exponentially with consecutive failures", () => { + const acct = account(); + markCooldown(acct); + const firstCooldown = acct.cooldownUntil; + markCooldown(acct); + assert.strictEqual(acct.consecutiveFails, 2); + // Second backoff (base*2^1) must be strictly larger than the first + // (base*2^0), modulo the shared jitter window — compare the floor. + assert.ok(acct.cooldownUntil - Date.now() > firstCooldown - Date.now() - 1000); + }); + + it("markCooldown uses the same magnitude regardless of why it was called (429 or network throw)", () => { + // No `short`/severity parameter: proxy-attributable failures (429, dead + // proxy) and shared-egress network throws use the identical formula — + // the repo's own established "transient, not clearly attributable" + // cooldown (errorConfig.ts TRANSIENT_COOLDOWN_MS/transientMax) already + // covers both cases at the same magnitude. The behavioral fix for + // shared-egress accounts lives in the caller's skip logic, not here. + const a = account(); + const b = account(); + markCooldown(a); + markCooldown(b); + // Both draw from the same base backoff ± up to 1s jitter — same formula, + // no separate "short" magnitude for either call site. + assert.ok( + Math.abs(a.cooldownUntil - b.cooldownUntil) <= 1000, + "same account state must produce cooldowns within the shared jitter window" + ); + }); + + it("markSuccess resets consecutiveFails to 0", () => { + const acct = account({ consecutiveFails: 5 }); + markSuccess(acct); + assert.strictEqual(acct.consecutiveFails, 0); + }); + + it("maskAccountId masks a real fingerprint to its first 8 chars + ellipsis", () => { + assert.strictEqual(maskAccountId("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), "aaaaaaaa…"); + }); + + it("maskAccountId reports the empty/default fingerprint as 'direct'", () => { + assert.strictEqual(maskAccountId(""), "direct"); + }); + + it("pickAccount skips accounts in cooldown and rotates nextAccountIdx", () => { + const a = account({ fingerprint: "a", cooldownUntil: Date.now() + 60_000 }); + const b = account({ fingerprint: "b", cooldownUntil: 0 }); + const state = { nextAccountIdx: 0 }; + const picked = pickAccount([a, b], state); + assert.strictEqual(picked.fingerprint, "b", "must skip the account still in cooldown"); + }); + + it("pickAccount falls back to the next index when every account is in cooldown", () => { + const a = account({ fingerprint: "a", cooldownUntil: Date.now() + 60_000 }); + const b = account({ fingerprint: "b", cooldownUntil: Date.now() + 60_000 }); + const state = { nextAccountIdx: 0 }; + const picked = pickAccount([a, b], state); + assert.strictEqual(picked.fingerprint, "a", "must still return an account, not throw/hang"); + }); + + it("pickAccount accepts a custom isReady predicate (e.g. JWT-freshness-aware)", () => { + const a = account({ fingerprint: "a", cooldownUntil: 0 }); + const b = account({ fingerprint: "b", cooldownUntil: 0 }); + const state = { nextAccountIdx: 0 }; + // Custom predicate rejects "a" for a reason cooldown alone wouldn't catch. + const picked = pickAccount([a, b], state, (acct: RotatableAccount) => acct.fingerprint !== "a"); + assert.strictEqual(picked.fingerprint, "b"); + }); + + it("isNetworkErrorRotatable is true only when the account has a configured proxy", () => { + const withProxy = account({ + proxy: { type: "http", host: "127.0.0.1", port: 8080 }, + }); + const withoutProxy = account({ proxy: null }); + assert.strictEqual(isNetworkErrorRotatable(withProxy), true); + assert.strictEqual(isNetworkErrorRotatable(withoutProxy), false); + }); +}); diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 1bf86905d3..8458edf857 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -1,4 +1,4 @@ -import { describe, it, before, beforeEach, after } from "node:test"; +import { describe, it, beforeEach, after } from "node:test"; import assert from "node:assert/strict"; import os from "node:os"; import path from "node:path"; @@ -30,7 +30,7 @@ const { isControlPlaneProxyDirectFallbackEnabled, } = await import("../../src/shared/utils/featureFlags.ts"); -const EXPECTED_FEATURE_FLAG_COUNT = 47; +const EXPECTED_FEATURE_FLAG_COUNT = 48; // ────────────────────────────────────────────────────── // Test group 1 — Flag definitions registry @@ -161,6 +161,18 @@ describe("featureFlagDefinitions", () => { assert.strictEqual(def.warningLevel, "danger"); }); + it("defines network rotation shared-egress guard as a network boolean flag enabled by default", () => { + const def = FEATURE_FLAG_DEFINITIONS.find( + (d) => d.key === "NETWORK_ROTATION_SHARED_EGRESS_GUARD" + ); + assert.ok(def, "NETWORK_ROTATION_SHARED_EGRESS_GUARD should exist"); + assert.strictEqual(def.category, "network"); + assert.strictEqual(def.type, "boolean"); + assert.strictEqual(def.defaultValue, "true"); + assert.strictEqual(def.requiresRestart, false); + assert.strictEqual(def.warningLevel, "info"); + }); + it("defines remote audio provider nodes as a network boolean flag disabled by default", () => { // Guards the egress default: with this on, /v1/audio/* may reach a provider node // hosted outside localhost. It must never become an implicit default (cf. #3963). diff --git a/tests/unit/mimocode-executor.test.ts b/tests/unit/mimocode-executor.test.ts index 3c01022c31..92e43ab13d 100644 --- a/tests/unit/mimocode-executor.test.ts +++ b/tests/unit/mimocode-executor.test.ts @@ -1,4 +1,4 @@ -import { describe, it } from "node:test"; +import { describe, it, beforeEach, afterEach } from "node:test"; import assert from "node:assert"; import { MimocodeExecutor, @@ -254,8 +254,6 @@ describe("mimocode providerRegistry entry", () => { }); describe("mimocode per-account proxy", () => { - const exec = new MimocodeExecutor(); - it("AccountProxyConfig type has required fields", () => { const config: AccountProxyConfig = { fingerprint: "abc123", @@ -498,6 +496,7 @@ interface TestAccountState { expiresAt: number; cooldownUntil: number; consecutiveFails: number; + proxy?: unknown; } interface ExecutorAccountAccess { @@ -625,3 +624,250 @@ describe("mimocode 400 classification (#2101/#4976)", () => { } }); }); + +describe("mimocode network-error rotation (parity with OpencodeExecutor)", () => { + function makeJwt(): string { + const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 }) + ).toString("base64url"); + return `${header}.${payload}.sig`; + } + + function twoAccountExecutor(proxies: [unknown, unknown]): MimocodeExecutor { + const exec = new MimocodeExecutor(); + const access = accountAccess(exec); + access.accounts = [ + { + fingerprint: "acct-a", + jwt: "", + expiresAt: 0, + cooldownUntil: 0, + consecutiveFails: 0, + proxy: proxies[0], + }, + { + fingerprint: "acct-b", + jwt: "", + expiresAt: 0, + cooldownUntil: 0, + consecutiveFails: 0, + proxy: proxies[1], + }, + ] as TestAccountState[]; + access.nextAccountIdx = 0; + return exec; + } + + const A_PROXY = { type: "http", host: "127.0.0.1", port: 8080 }; + const B_PROXY = { type: "http", host: "127.0.0.1", port: 8081 }; + + it("rotates to the next account on a network throw when the failed account has a dedicated proxy", async () => { + const testExec = twoAccountExecutor([A_PROXY, B_PROXY]); + // Force both dispatch legs (bootstrap + chat) through the plain `fetch()` + // fallback instead of a real undici proxy dispatcher — this test exercises + // the rotation DECISION (account.proxy is configured → rotate), not actual + // proxy network I/O, which has its own dedicated dispatcher tests below. + (testExec as unknown as { getProxyDispatcher: () => undefined }).getProxyDispatcher = () => + undefined; + let chatCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const urlStr = String(url); + if (urlStr.includes("/api/free-ai/bootstrap")) { + return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 }); + } + if (urlStr.includes("/api/free-ai/openai/chat")) { + chatCalls++; + if (chatCalls === 1) throw new Error("ECONNRESET"); + return new Response(JSON.stringify({ id: "ok", choices: [] }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${urlStr}`); + }) as typeof fetch; + + const warnCalls: string[] = []; + try { + const result = await testExec.execute({ + model: "mimo-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: { + providerSpecificData: { + fingerprints: ["acct-a", "acct-b"], + accountProxies: [ + { fingerprint: "acct-a", proxy: A_PROXY }, + { fingerprint: "acct-b", proxy: B_PROXY }, + ], + }, + }, + log: { + debug: () => {}, + info: () => {}, + warn: (_tag: unknown, msg: string) => warnCalls.push(msg), + error: () => {}, + }, + }); + + assert.strictEqual(chatCalls, 2, "should retry on the next account after the throw"); + assert.strictEqual(result.response.status, 200); + const acctA = accountAccess(testExec).accounts[0]; + assert.ok(acctA.cooldownUntil > Date.now(), "account with a dedicated proxy must cool down"); + assert.ok( + warnCalls.some((m) => /network error/i.test(m)), + `expected a "network error" warn log; got=${JSON.stringify(warnCalls)}` + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + + describe("NETWORK_ROTATION_SHARED_EGRESS_GUARD", () => { + const FLAG = "NETWORK_ROTATION_SHARED_EGRESS_GUARD"; + let originalEnvValue: string | undefined; + + beforeEach(() => { + originalEnvValue = process.env[FLAG]; + }); + + afterEach(() => { + if (originalEnvValue === undefined) delete process.env[FLAG]; + else process.env[FLAG] = originalEnvValue; + }); + + it("rotates to a proxied account after a proxy-less account throws (mixed fleet, guard on by default)", async () => { + const testExec = twoAccountExecutor([null, B_PROXY]); + // Force both dispatch legs through the plain `fetch()` fallback instead + // of a real undici proxy dispatcher — this test exercises the rotation + // DECISION, not actual proxy network I/O. + (testExec as unknown as { getProxyDispatcher: () => undefined }).getProxyDispatcher = () => + undefined; + let chatCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const urlStr = String(url); + if (urlStr.includes("/api/free-ai/bootstrap")) { + return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 }); + } + if (urlStr.includes("/api/free-ai/openai/chat")) { + chatCalls++; + if (chatCalls === 1) throw new Error("ETIMEDOUT"); + return new Response(JSON.stringify({ id: "ok", choices: [] }), { status: 200 }); + } + throw new Error(`unexpected fetch: ${urlStr}`); + }) as typeof fetch; + + try { + const result = await testExec.execute({ + model: "mimo-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: { + providerSpecificData: { + fingerprints: ["acct-a", "acct-b"], + accountProxies: [{ fingerprint: "acct-b", proxy: B_PROXY }], + }, + }, + log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }); + + assert.strictEqual(chatCalls, 2, "the proxied account (B) must still be tried"); + assert.strictEqual(result.response.status, 200); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("makes a single real network call when no account has a configured proxy (guard on by default)", async () => { + const testExec = twoAccountExecutor([null, null]); + let chatCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const urlStr = String(url); + if (urlStr.includes("/api/free-ai/bootstrap")) { + return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 }); + } + if (urlStr.includes("/api/free-ai/openai/chat")) { + chatCalls++; + throw new Error("ETIMEDOUT"); + } + throw new Error(`unexpected fetch: ${urlStr}`); + }) as typeof fetch; + + try { + const result = await testExec.execute({ + model: "mimo-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: {}, + log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, + }); + + assert.strictEqual( + chatCalls, + 1, + "remaining proxy-less accounts must be skipped without a network call once the shared egress is known down" + ); + assert.strictEqual(result.response.status, 502); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("fails fast without rotating when the guard is disabled (legacy behavior)", async () => { + process.env[FLAG] = "false"; + const testExec = twoAccountExecutor([null, null]); + let chatCalls = 0; + const originalFetch = globalThis.fetch; + globalThis.fetch = (async (url: unknown) => { + const urlStr = String(url); + if (urlStr.includes("/api/free-ai/bootstrap")) { + return new Response(JSON.stringify({ jwt: makeJwt() }), { status: 200 }); + } + if (urlStr.includes("/api/free-ai/openai/chat")) { + chatCalls++; + throw new Error("ETIMEDOUT"); + } + throw new Error(`unexpected fetch: ${urlStr}`); + }) as typeof fetch; + + const warnCalls: string[] = []; + try { + const result = await testExec.execute({ + model: "mimo-auto", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: {}, + log: { + debug: () => {}, + info: () => {}, + warn: (_tag: unknown, msg: string) => warnCalls.push(msg), + error: () => {}, + }, + }); + + assert.strictEqual( + chatCalls, + 1, + "must NOT retry against another account sharing the same egress" + ); + const acctA = accountAccess(testExec).accounts[0]; + assert.strictEqual( + acctA.cooldownUntil, + 0, + "an account without a dedicated proxy must not be cooled down for a shared-egress failure" + ); + assert.strictEqual(result.response.status, 502); + assert.ok( + warnCalls.some((m) => /network error/i.test(m) && /not rotating/i.test(m)), + `expected a "network error … not rotating" warn log; got=${JSON.stringify(warnCalls)}` + ); + } finally { + globalThis.fetch = originalFetch; + } + }); + }); +}); diff --git a/tests/unit/opencode-proxy-rotation-4954.test.ts b/tests/unit/opencode-proxy-rotation-4954.test.ts index 43a1458e1a..c2f1484d3d 100644 --- a/tests/unit/opencode-proxy-rotation-4954.test.ts +++ b/tests/unit/opencode-proxy-rotation-4954.test.ts @@ -2,6 +2,7 @@ import { describe, it, beforeEach, afterEach, before, after } from "node:test"; import assert from "node:assert"; import net from "node:net"; import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts"; +import type { ExecutorLog } from "../../open-sse/executors/base.ts"; import { resolveProxyForRequest, runWithAppliedProxyCapture, @@ -57,17 +58,21 @@ after(() => { serverB?.close(); }); -function credentialsWithProxies() { +/** Two fingerprints; `withProxies: false` omits accountProxies so both accounts + * share the default egress instead of each having a dedicated proxy. */ +function credentialsWithProxies(withProxies = true) { return { apiKey: null, accessToken: null, connectionId: "noauth", providerSpecificData: { fingerprints: [ACCOUNT_A, ACCOUNT_B], - accountProxies: [ - { fingerprint: ACCOUNT_A, proxy: { type: "http", host: "127.0.0.1", port: portA } }, - { fingerprint: ACCOUNT_B, proxy: { type: "http", host: "127.0.0.1", port: portB } }, - ], + ...(withProxies && { + accountProxies: [ + { fingerprint: ACCOUNT_A, proxy: { type: "http", host: "127.0.0.1", port: portA } }, + { fingerprint: ACCOUNT_B, proxy: { type: "http", host: "127.0.0.1", port: portB } }, + ], + }), }, } as any; } @@ -89,7 +94,8 @@ describe("OpencodeExecutor per-account proxy + rotation (#4954)", () => { function installFetchStub(statuses: number[]) { let call = 0; globalThis.fetch = (async (input: any) => { - const url = typeof input === "string" ? input : input?.url || String(input); + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const resolved = resolveProxyForRequest(url); let host: string | null = null; let port: string | null = null; @@ -169,6 +175,221 @@ describe("OpencodeExecutor per-account proxy + rotation (#4954)", () => { } }); + it("rotates to the next account on a network throw (not just 429)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async (input: Parameters[0]) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const resolved = resolveProxyForRequest(url); + observed.push({ + source: resolved.source, + host: resolved.proxyUrl ? new URL(resolved.proxyUrl).hostname : null, + port: resolved.proxyUrl ? new URL(resolved.proxyUrl).port : null, + }); + call++; + if (call === 1) { + throw new Error("ECONNRESET: connection reset by peer"); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + try { + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(), + log, + }); + + assert.strictEqual( + (result as { response: { status: number } }).response.status, + 200, + "a throw on account A must not abort the request — account B must be tried" + ); + assert.ok(observed.length >= 2, "should have retried on a second account after the throw"); + assert.notStrictEqual( + observed[0].port, + observed[1].port, + "rotation must switch to a different account/proxy after a throw" + ); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + + it("logs a network-error rotation and does not swallow it silently", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async () => { + call++; + if (call === 1) throw new Error("ETIMEDOUT"); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + const warnCalls: Array<{ tag: unknown; msg: string }> = []; + const spyLog: ExecutorLog = { + debug() {}, + info() {}, + warn: (tag, msg) => { + warnCalls.push({ tag, msg }); + }, + error() {}, + }; + + try { + await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(), + log: spyLog, + }); + + assert.ok( + warnCalls.some((c) => c.tag === "OPENCODE" && /network error/i.test(c.msg)), + `expected a warn-level "network error" log; got=${JSON.stringify(warnCalls)}` + ); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + + describe("NETWORK_ROTATION_SHARED_EGRESS_GUARD", () => { + const FLAG = "NETWORK_ROTATION_SHARED_EGRESS_GUARD"; + let originalEnvValue: string | undefined; + + beforeEach(() => { + originalEnvValue = process.env[FLAG]; + }); + + afterEach(() => { + if (originalEnvValue === undefined) delete process.env[FLAG]; + else process.env[FLAG] = originalEnvValue; + }); + + it("rotates to a proxied account after a proxy-less account throws (mixed fleet, guard on by default)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async () => { + call++; + if (call === 1) throw new Error("ETIMEDOUT"); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof globalThis.fetch; + + try { + // ACCOUNT_A has no proxy, ACCOUNT_B does — credentialsWithProxies(true) + // only configures a proxy for accounts present in accountProxies; give + // A no entry so it stays proxy-less while B keeps its dedicated proxy. + const credentials = credentialsWithProxies(); + credentials.providerSpecificData.accountProxies = + credentials.providerSpecificData.accountProxies.filter( + (ap: { fingerprint: string }) => ap.fingerprint !== ACCOUNT_A + ); + + const result = await exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials, + log, + }); + + assert.strictEqual( + (result as { response: { status: number } }).response.status, + 200, + "the proxied account (B) must still be tried and must succeed the request" + ); + assert.strictEqual(call, 2, "exactly one throw (A) then one success (B)"); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + + it("makes a single real network call when no account has a configured proxy (guard on by default)", async () => { + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async () => { + call++; + throw new Error("ETIMEDOUT"); + }) as typeof globalThis.fetch; + + try { + await assert.rejects( + () => + exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(false), + log, + }), + /ETIMEDOUT/, + "must ultimately propagate once no candidate account remains" + ); + assert.strictEqual( + call, + 1, + "remaining proxy-less accounts must be skipped without a network call once the shared egress is known down" + ); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + + it("propagates immediately on the first proxy-less throw when the guard is disabled", async () => { + process.env[FLAG] = "false"; + const exec = new OpencodeExecutor("opencode-zen"); + let call = 0; + const originalFetchForThrow = globalThis.fetch; + globalThis.fetch = (async () => { + call++; + throw new Error("ETIMEDOUT"); + }) as typeof globalThis.fetch; + + try { + await assert.rejects( + () => + exec.execute({ + model: "deepseek-v4-flash-free", + body: { messages: [{ role: "user", content: "hi" }], stream: false }, + stream: false, + signal: null, + credentials: credentialsWithProxies(false), + log, + }), + /ETIMEDOUT/, + "a network throw on a proxy-less account must propagate, not be swallowed into rotation" + ); + assert.strictEqual( + call, + 1, + "must not retry against another account when the guard is disabled" + ); + } finally { + globalThis.fetch = originalFetchForThrow; + } + }); + }); + // #5217 (Gap 2): the per-request account/proxy selection log was log.debug, which // is hidden at the default APP_LOG_LEVEL=info — operators could not see which // account/proxy a request rotated to. It must be emitted at info level. From d9e24d84d8378e136959be98457b77e07fa0d803 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:16:09 -0300 Subject: [PATCH 19/36] chore(deps): bump github/codeql-action from 4.37.4 to 4.37.6 (#10405) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.6) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 9cca65ac09..6e4adc0192 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -372,7 +372,7 @@ jobs: - name: Upload Trivy SARIF to Security tab if: needs.prepare.outputs.version != 'main' continue-on-error: true - uses: github/codeql-action/upload-sarif@v4.37.4 + uses: github/codeql-action/upload-sarif@v4.37.6 with: sarif_file: trivy-results.sarif category: trivy-image From 142bd5019f543cc6001dd805f402d3db0cfa3a23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:16:14 -0300 Subject: [PATCH 20/36] chore(deps): bump github/codeql-action/analyze from 4.37.4 to 4.37.6 (#10406) Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3) --- updated-dependencies: - dependency-name: github/codeql-action/analyze dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b32487da27..013e1b0962 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -26,6 +26,6 @@ jobs: with: languages: javascript-typescript queries: security-extended - - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + - uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: "/language:javascript-typescript" From 3c8432791e9bd5e8af0e8f4939449119cddc550d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:16:18 -0300 Subject: [PATCH 21/36] chore(deps): bump github/codeql-action/init from 4.37.4 to 4.37.6 (#10407) Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.37.4 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...5595ccaf912efad79be6eef63a5619ff05969be3) --- updated-dependencies: - dependency-name: github/codeql-action/init dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 013e1b0962..36f8254977 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + - uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: javascript-typescript queries: security-extended From b75f7dde933dbc33500a54e08558c27b4848ccdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:46:23 +0330 Subject: [PATCH 22/36] fix(guardrails): reroute zero-vision combos through the vision bridge (#10415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(guardrails): reroute zero-vision combos through the vision bridge Named combos whose model targets all lack vision support are never reroute-eligible: the bridge only attempts the describe path, and when describing cannot run or fails the raw images stay in the payload and the request dies in the combo capability filter with capability_mismatch. getComboVisionBridgeDecision now returns a "no-vision" verdict for combos with zero vision-capable targets, and preCall treats it as reroute-eligible with the same credential guards as single text-only models, falling back to describe only when no usable reroute target exists. * chore(changelog): fragment for #10415 vision bridge combo reroute * fix(guardrails): extend allNull stub fallback to no-vision combos Reviewer follow-up (#10415): the allNull stub-text fallback at the end of preCall only fired for comboVisionBridgeDecision === 'process'. In the compound-failure case for a zero-vision combo — reroute target without usable credentials AND every describe call failing — raw images were preserved and the original capability_mismatch recurred, because a no-vision combo has no target that can consume images. Include 'no-vision' in the guard: stub text is strictly better than raw bytes no combo target can consume. Adds a double-failure unit test. * ci: re-run dast-smoke (Build CLI bundle runner timeout flake) * fix(build): bound and retry the opencode-plugin npm install in prepublish The plugin's node_modules is gitignored, so every fresh CI checkout runs a full npm install inside @omniroute/opencode-plugin during build:cli. npm's unbounded fetch retries turn a stalled registry CDN connection (the recurring onnxruntime-class ETIMEDOUT flake) into a 20-30 minute hang — the DAST 'Build CLI bundle' step has been cancelled at the 30m cap repeatedly. - Bound npm fetch: --fetch-timeout 60s, 2 retries with capped backoff — a stalled connection now fails fast instead of hanging the job. - Retry the install up to 3 times with a 10s pause between attempts, so transient CDN failures recover in-build. Net effect: the step either completes (network OK) or fails quickly with a clear error (network down) — it can no longer eat the whole job budget. * ci(dast): use existing npm-ci-retry action instead of bare npm ci dast-smoke died at 'Run npm ci' with connect ETIMEDOUT to the onnxruntime-node binary CDN (Microsoft 150.171.x.x) — the same transient CDN flake class that has hit Vitest/Quality Gates before. quality.yml already wraps npm ci in ./.github/actions/npm-ci-retry (3 attempts, exponential backoff); dast-smoke was the one workflow still using a bare install. Use the existing action for consistency. * ci(quality): use the npm-ci-retry action on every install step Fast Quality Gates failed on the recurring onnxruntime-node postinstall ETIMEDOUT (Microsoft CDN 150.171.x.x) - the same transient flake that has hit Vitest and dast-smoke today. Only the Build job used the retry action; the other five jobs (Docs, Fast Quality Gates, Vitest, Unit Tests, changelog) still ran a bare install and die on any CDN hiccup. Use the existing retry action (3 attempts, exponential backoff) on every install step for consistency. --------- Co-authored-by: Rouzbeh --- .github/workflows/dast-smoke.yml | 2 +- .github/workflows/quality.yml | 12 +- .../10415-vision-bridge-combo-reroute.md | 1 + scripts/build/prepublish.ts | 74 ++++- src/lib/guardrails/visionBridge.ts | 57 +++- .../visionBridge-combo-reroute.test.ts | 292 ++++++++++++++++++ 6 files changed, 402 insertions(+), 36 deletions(-) create mode 100644 changelog.d/fixes/10415-vision-bridge-combo-reroute.md create mode 100644 tests/unit/guardrails/visionBridge-combo-reroute.test.ts diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index f4e2d65155..23055c46e4 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -37,7 +37,7 @@ jobs: with: node-version: "24" cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry - name: Build CLI bundle env: OMNIROUTE_BUILD_BACKEND_ONLY: "1" diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index dd34601323..9047db295e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -137,7 +137,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry # One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently). - run: npm run check:api-docs-refs - name: Docs accuracy (fabricated-docs + i18n mirrors, strict) @@ -181,7 +181,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry - name: Restore ESLint file cache uses: actions/cache@v6 with: @@ -430,7 +430,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry # WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR, # which is where flaky-detection volume actually comes from (ci.yml's heavy # jobs only run on the release PR). Advisory upload, own-origin only. @@ -476,7 +476,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry # QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do # comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes # silenciosamente não rodavam no fast path) e o setupPolyfill não era importado. @@ -516,7 +516,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry - name: Restore ESLint file cache uses: actions/cache@v6 with: @@ -583,7 +583,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry - name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result) run: npm run check:changelog-integrity - name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo) diff --git a/changelog.d/fixes/10415-vision-bridge-combo-reroute.md b/changelog.d/fixes/10415-vision-bridge-combo-reroute.md new file mode 100644 index 0000000000..a3df3019c4 --- /dev/null +++ b/changelog.d/fixes/10415-vision-bridge-combo-reroute.md @@ -0,0 +1 @@ +- **fix(guardrails):** Vision Bridge now reroutes whole requests for named combos whose targets have zero vision-capable models (previously such image requests died with `capability_mismatch` when the describe path could not run), and when the fallback describe path also fails for every image the request degrades to explicit `(unavailable)` stub text instead of preserving images the combo cannot consume ([#10415](https://github.com/diegosouzapw/OmniRoute/pull/10415)) — thanks @rqzbeh diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index 6de4af27e2..e8872c0a9a 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -471,24 +471,66 @@ if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package // needs the plugin's own devDependencies (typescript, @opencode-ai/plugin // types). Without this install a fresh CI publish fails at this step. if (!existsSync(join(opencodePluginSrc, "node_modules"))) { + // The plugin's node_modules is gitignored, so a fresh CI checkout + // ALWAYS installs here. The registry CDN is intermittently flaky + // (onnxruntime-class ETIMEDOUTs to the Microsoft CDN have repeatedly + // stalled CI npm steps for 20+ minutes), and npm's unbounded fetch + // retries turn a stalled connection into a hang that eats the whole + // job budget. Bound the fetch and retry the install a few times: + // transient network failures fail fast and recover instead of hanging. const npmEntry = resolveBundledNpmEntry("npm-cli.js"); - if (npmEntry) { - execFileSync(process.execPath, [npmEntry, "install", "--no-audit", "--no-fund"], { - cwd: opencodePluginSrc, - stdio: "inherit", - }); - } else if (process.platform !== "win32") { - // No bundled npm entry found (non-standard Node layout). Plain `npm` is - // safe here — the .cmd-shim hazard #8858 guards against is Windows-only. - execFileSync("npm", ["install", "--no-audit", "--no-fund"], { - cwd: opencodePluginSrc, - stdio: "inherit", - }); - } else { - throw new Error( - "npm-cli.js not found next to the running Node binary; cannot install the plugin dependencies without falling back to a .cmd shim." - ); + const installArgs = [ + "install", + "--no-audit", + "--no-fund", + "--fetch-retries=2", + "--fetch-retry-mintimeout=2000", + "--fetch-retry-maxtimeout=30000", + "--fetch-timeout=60000", + ]; + const runPluginInstall = () => { + if (npmEntry) { + execFileSync(process.execPath, [npmEntry, ...installArgs], { + cwd: opencodePluginSrc, + stdio: "inherit", + }); + } else if (process.platform !== "win32") { + // No bundled npm entry found (non-standard Node layout). Plain `npm` is + // safe here — the .cmd-shim hazard #8858 guards against is Windows-only. + execFileSync("npm", installArgs, { + cwd: opencodePluginSrc, + stdio: "inherit", + }); + } else { + throw new Error( + "npm-cli.js not found next to the running Node binary; cannot install the plugin dependencies without falling back to a .cmd shim." + ); + } + }; + const sleepSync = (ms: number) => + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + let installError: any = null; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + if (attempt > 1) { + console.log( + ` 🔄 @omniroute/opencode-plugin npm install retry (attempt ${attempt}/3)` + ); + } + runPluginInstall(); + installError = null; + break; + } catch (err: any) { + installError = err; + if (attempt < 3) { + console.warn( + ` ⚠️ plugin npm install failed (attempt ${attempt}/3): ${err?.message ?? String(err)} — retrying in 10s` + ); + sleepSync(10_000); + } + } } + if (installError) throw installError; } runBuildTool("tsup", "tsup", [], { cwd: opencodePluginSrc, diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts index 1fc032c374..85cd792512 100644 --- a/src/lib/guardrails/visionBridge.ts +++ b/src/lib/guardrails/visionBridge.ts @@ -3,6 +3,9 @@ * Intercepts image-bearing requests to non-vision models. * For individual non-vision models: reroutes to the fastest available vision-capable model. * For combos with non-vision targets: extracts descriptions via vision model and replaces images with text. + * For combos with ZERO vision-capable targets: falls back to whole-request reroute to a + * vision-capable model (same semantics as an individual text-only model), so image + * requests do not die in the combo capability filter when describing is impossible. */ import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base"; @@ -31,7 +34,7 @@ import { export { isProviderConnectionUsable, hasUsableCredentialsForModel }; -type ComboVisionBridgeDecision = "process" | "skip" | "not-combo"; +type ComboVisionBridgeDecision = "process" | "skip" | "not-combo" | "no-vision"; export function resolveVisionComboName(mapping: Record): string | null { const comboName = mapping.comboName ?? mapping.name ?? null; @@ -40,10 +43,15 @@ export function resolveVisionComboName(mapping: Record): string /// Check if a combo model should trigger vision bridge processing. /// Resolves combo targets and returns: -/// - "process" if any target cannot be proven vision-capable +/// - "process" if some (but not all) model targets lack proven vision support /// - "skip" if all model targets can handle images directly +/// - "no-vision" when the combo has model targets but NONE can handle images — +/// the combo behaves like a single text-only model, so the bridge may +/// whole-request reroute to a vision-capable model (mirroring non-combos) /// - "not-combo" when the model is not a combo/mapping -async function getComboVisionBridgeDecision(model: string): Promise { +export async function getComboVisionBridgeDecision( + model: string +): Promise { try { const { getComboByName } = await import("@/lib/localDb"); const { resolveComboForModel } = await import("@/lib/db/modelComboMappings"); @@ -70,7 +78,10 @@ async function getComboVisionBridgeDecision(model: string): Promise; if (s.kind === "combo-ref") return "process"; @@ -79,8 +90,10 @@ async function getComboVisionBridgeDecision(model: string): Promise d === null); - if (allNull && comboVisionBridgeDecision === "process") { + if ( + allNull && + (comboVisionBridgeDecision === "process" || comboVisionBridgeDecision === "no-vision") + ) { for (let i = 0; i < descriptions.length; i++) { descriptions[i] = `[Image ${i + 1}]: (unavailable — no vision-capable provider connected)`; } diff --git a/tests/unit/guardrails/visionBridge-combo-reroute.test.ts b/tests/unit/guardrails/visionBridge-combo-reroute.test.ts new file mode 100644 index 0000000000..7757d028b6 --- /dev/null +++ b/tests/unit/guardrails/visionBridge-combo-reroute.test.ts @@ -0,0 +1,292 @@ +/** + * Vision Bridge × named-combo reroute tests. + * + * Regression: a named combo whose targets have ZERO vision-capable models was + * never reroute-eligible. The bridge only described images for it, and when + * the describe path could not run (unreachable bridge model, failed self-loop, + * missing credentials) the raw images stayed in the payload, the combo + * capability filter excluded every target, and the request died with + * capability_mismatch — "vision bridge does not affect combo models". + * + * Fix under test: `getComboVisionBridgeDecision` returns "no-vision" for a + * combo with model targets but no vision-capable target, and preCall treats + * that decision as reroute-eligible (mirroring non-combo text-only models), + * falling back to describe only when no usable reroute target exists. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-vb-combo-reroute-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { VisionBridgeGuardrail, getComboVisionBridgeDecision } = + await import("../../../src/lib/guardrails/visionBridge.ts"); +const { resetGuardrailsForTests } = await import("../../../src/lib/guardrails/registry.ts"); +const { getResolvedModelCapabilities } = await import("../../../src/lib/modelCapabilities.ts"); +const core = await import("../../../src/lib/db/core.ts"); +const combosDb = await import("../../../src/lib/db/combos.ts"); +const mappingsDb = await import("../../../src/lib/db/modelComboMappings.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function createCombo(name, models, overrides = {}) { + return combosDb.createCombo({ + name, + models, + strategy: "priority", + ...overrides, + }); +} + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +const VISION_MODEL = "openai/gpt-4o"; +const TEXT_MODEL_A = "google/gemma-2-27b"; +const TEXT_MODEL_B = "mistral/mistral-large-latest"; + +// Fail loudly if the static vision heuristic drifts: these fixtures drive +// every assertion in this file. +test("fixture models have the expected static vision capability", () => { + assert.equal(getResolvedModelCapabilities(VISION_MODEL).supportsVision, true); + assert.notEqual(getResolvedModelCapabilities(TEXT_MODEL_A).supportsVision, true); + assert.notEqual(getResolvedModelCapabilities(TEXT_MODEL_B).supportsVision, true); +}); + +const mockSettings = { + visionBridgeEnabled: true, + visionBridgeModel: VISION_MODEL, + visionBridgePrompt: "Describe this image concisely.", + visionBridgeTimeout: 30000, + visionBridgeMaxImages: 10, +}; + +let visionCallCount = 0; + +// Each describe-path test uses a UNIQUE prompt: the shared describe cache keys +// on (contentRef, prompt, model), so a reused prompt would serve a cached +// description and skip callVisionModel, breaking the assertion on call count. +function createGuardrail(depsOverrides = {}, prompt = "Describe this image concisely.") { + return new VisionBridgeGuardrail({ + deps: { + getSettings: async () => ({ ...mockSettings, visionBridgePrompt: prompt }), + callVisionModel: async () => { + visionCallCount++; + return "A red circle on a white background"; + }, + // null = fail-open (no credential DB in unit tests), matching the + // existing visionBridge.test.ts convention. + hasUsableCredentials: async () => null, + ...depsOverrides, + }, + }); +} + +const IMAGE_PAYLOAD = { + model: "text-only-combo", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image in one sentence." }, + { + type: "image_url", + image_url: { + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + }, + ], + }, + ], +}; + +function hasImagePart(messages) { + return JSON.stringify(messages).includes("image_url"); +} + +// GuardrailResult types modifiedPayload as `unknown`; the existing +// visionBridge.test.ts casts it the same way. +type ModifiedBody = { model?: string; messages?: unknown[] }; +function asModifiedBody(result: { modifiedPayload?: unknown }): ModifiedBody { + return (result.modifiedPayload ?? {}) as ModifiedBody; +} + +// ── getComboVisionBridgeDecision ──────────────────────────────────────────── + +test("decision: combo with zero vision-capable targets returns 'no-vision'", async () => { + await createCombo("text-only-combo", [ + { provider: "google", model: TEXT_MODEL_A }, + { provider: "mistral", model: TEXT_MODEL_B }, + ]); + assert.equal(await getComboVisionBridgeDecision("text-only-combo"), "no-vision"); +}); + +test("decision: combo with all vision-capable targets returns 'skip'", async () => { + await createCombo("vision-combo", [ + { provider: "openai", model: VISION_MODEL }, + { provider: "anthropic", model: "anthropic/claude-sonnet-4-20250514" }, + ]); + assert.equal(await getComboVisionBridgeDecision("vision-combo"), "skip"); +}); + +test("decision: mixed combo (some vision, some not) returns 'process'", async () => { + await createCombo("mixed-combo", [ + { provider: "openai", model: VISION_MODEL }, + { provider: "google", model: TEXT_MODEL_A }, + ]); + assert.equal(await getComboVisionBridgeDecision("mixed-combo"), "process"); +}); + +test("decision: unknown model returns 'not-combo'", async () => { + assert.equal(await getComboVisionBridgeDecision("not-a-combo"), "not-combo"); +}); + +test("decision: model-combo mapping routes to the combo decision", async () => { + const combo = await createCombo("mapped-text-only", [ + { provider: "google", model: TEXT_MODEL_A }, + ]); + await mappingsDb.createModelComboMapping({ + pattern: "mapped-model-alias", + comboId: combo.id as string, + priority: 20, + description: "test alias", + }); + assert.equal(await getComboVisionBridgeDecision("mapped-model-alias"), "no-vision"); +}); + +// ── preCall: no-vision combo reroutes whole request ───────────────────────── + +test("preCall: zero-vision combo reroutes the whole request to the bridge model", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [ + { provider: "google", model: TEXT_MODEL_A }, + { provider: "mistral", model: TEXT_MODEL_B }, + ]); + visionCallCount = 0; + const guardrail = createGuardrail(); + const result = await guardrail.preCall(IMAGE_PAYLOAD, {}); + + assert.equal(result.block, false); + // Rerouted: model swapped to the vision bridge model, image bytes KEPT. + assert.equal(asModifiedBody(result).model, VISION_MODEL); + assert.equal(result.meta.rerouted, true); + assert.equal(result.meta.fromModel, "text-only-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), true); + // Describe never ran — no extra vision call. + assert.equal(visionCallCount, 0); +}); + +test("preCall: zero-vision combo falls back to describe when reroute target is unusable", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [{ provider: "google", model: TEXT_MODEL_A }]); + visionCallCount = 0; + // Reroute target has no usable credentials → describe path must run. + const guardrail = createGuardrail( + { hasUsableCredentials: async () => false }, + "Describe the fallback image." + ); + const result = await guardrail.preCall(IMAGE_PAYLOAD, {}); + + assert.equal(result.block, false); + assert.equal(result.meta.rerouted, undefined); + // Images replaced with the described text; combo model kept. + assert.equal(asModifiedBody(result).model, "text-only-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), false); + assert.equal(visionCallCount, 1); +}); + +test("preCall: no-vision combo, unusable reroute target AND describe failure -> stub text", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [{ provider: "google", model: TEXT_MODEL_A }]); + visionCallCount = 0; + // Double failure: the reroute target has no usable credentials AND the + // describe call fails for every image. The allNull stub fallback must fire + // for "no-vision" too — otherwise the raw images stay in the payload, the + // combo capability filter rejects every target, and the original + // capability_mismatch recurs. + const guardrail = createGuardrail( + { + hasUsableCredentials: async () => false, + callVisionModel: async () => { + visionCallCount++; + throw new Error("no vision-capable provider connected"); + }, + }, + "Describe the double-failure image." + ); + const result = await guardrail.preCall(IMAGE_PAYLOAD, {}); + + assert.equal(result.block, false); + assert.equal(result.meta.rerouted, undefined); + // Combo model kept; raw image replaced with the stub text. + assert.equal(asModifiedBody(result).model, "text-only-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), false); + assert.match( + JSON.stringify(asModifiedBody(result).messages), + /\(unavailable — no vision-capable provider connected\)/ + ); + assert.equal(visionCallCount, 1); +}); + +test("preCall: zero-vision combo with no images is left untouched", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [{ provider: "google", model: TEXT_MODEL_A }]); + visionCallCount = 0; + const guardrail = createGuardrail(); + const result = await guardrail.preCall( + { + model: "text-only-combo", + messages: [{ role: "user", content: "no images here" }], + }, + {} + ); + assert.equal(result.block, false); + assert.equal(result.modifiedPayload, undefined); + assert.equal(visionCallCount, 0); +}); + +// ── preCall: unchanged semantics for other combo shapes ───────────────────── + +test("preCall: all-vision combo still skips the bridge entirely", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("vision-combo", [{ provider: "openai", model: VISION_MODEL }]); + visionCallCount = 0; + const guardrail = createGuardrail(); + const result = await guardrail.preCall({ ...IMAGE_PAYLOAD, model: "vision-combo" }, {}); + assert.equal(result.block, false); + assert.equal(result.modifiedPayload, undefined); + assert.equal(visionCallCount, 0); +}); + +test("preCall: mixed combo keeps the describe path (no reroute, model unchanged)", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("mixed-combo", [ + { provider: "openai", model: VISION_MODEL }, + { provider: "google", model: TEXT_MODEL_A }, + ]); + visionCallCount = 0; + const guardrail = createGuardrail({}, "Describe the mixed-combo image."); + const result = await guardrail.preCall({ ...IMAGE_PAYLOAD, model: "mixed-combo" }, {}); + + assert.equal(result.block, false); + // Mixed combo is NOT reroute-eligible: model stays, images described. + assert.equal(result.meta.rerouted, undefined); + assert.equal(asModifiedBody(result).model, "mixed-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), false); + assert.equal(visionCallCount, 1); +}); From e44a409aa9f0121e9265d58534cd6020f547f22f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:46:27 +0330 Subject: [PATCH 23/36] fix(antigravity): classify geo-blocked egress, exclude account, real connection probe (#10420) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(antigravity): classify geo-blocked egress, exclude account, real connection probe Google refuses the Cloud Code model API from unsupported egress locations with 400 FAILED_PRECONDITION "User location is not supported for the API use." Previously this surfaced as a cryptic "Antigravity upstream error (400)", never excluded the account, and the dashboard connection test stayed green because it only probed the (non-geo-restricted) OAuth userinfo endpoint. - errorClassifier: new GEO_BLOCKED type + isGeoBlockedError detection (400/403 + location-not-supported wording); non-terminal classification. - chatCore fallback: GEO_BLOCKED marks the connection and caches a 24h rate-limit-until exclusion so routing moves to other accounts instead of re-selecting the same one; never bans/expires the account. - auth: GEO_BLOCKED joins the non-terminal group (no banned/expired state). - antigravityUpstreamError: geo refusals carry an actionable message (egress location vs account problem, proxy-in-supported-region guidance). - connection test: antigravity/agy now probe the REAL streamGenerateContent surface (buildProbe), so a green tick means the model path actually works and a geo-blocked egress shows red with a clear diagnosis. * chore(changelog): fragment for #10420 antigravity geo-block resilience * chore(pr): drop prettier-version drift noise, keep only real hunks The earlier format pass (local prettier differs from the repo's pinned version) rewrapped unrelated lines in chatCore.ts and the provider test route. Restore the base formatting and re-apply only the GEO_BLOCKED fallback branch and the buildProbe connection-test changes. * fix(antigravity): strip competing-agent system prompts (429 RESOURCE_EXHAUSTED) Port decolua/9router b566b20, generalized: Antigravity flags system prompts advertising competing agents ('You are a Claude agent, built on Anthropic's Claude Agent SDK.' — Zed, Claude Code, etc.) and answers with a 429 quota error. sanitizeAntigravityGeminiRequest now strips known competitor identity sentences from systemInstruction.parts before dispatch; surrounding instruction text is untouched and non-matching prompts pass through without allocation. * chore(changelog): cover competitive prompt strip in #10420 fragment * fix(antigravity): scope GEO_BLOCKED classification to Google AI surfaces Address reviewer feedback: classifyProviderError is shared across every provider, so a lookalike 'not available in your region' body from an unrelated upstream must not receive the egress-fixable 24h exclusion treatment. Gate GEO_BLOCKED behind isGeoBlockEligibleProvider, which matches the surfaces that actually emit Google's regional-availability refusal: Cloud Code / Gemini Code Assist (antigravity, agy, cloudcode*), the Gemini Developer API (gemini, gemini-cli, vertex), plus a registry-driven fallback on executor/format. Non-Google providers fall through to their existing 400/403 classification (typically null for an unclassified 400), so a permanent block still follows its own path. * ci: re-run quality gates Trigger a fresh CI run for the PR: the previous run's 'Vitest (fast-path)' job failed in 'npm ci' because the onnxruntime-node postinstall could not download its binary from the Microsoft CDN (connect ETIMEDOUT 150.171.109.118:443). No tests ran; no code changed in this commit. * fix(antigravity): guard provider before registry lookup in geo-block gate isGeoBlockEligibleProvider passes the raw provider (string | null | undefined) to getRegistryEntry(provider: string), failing typecheck:core and the ts7-diagnostics ratchet (TS2345 at errorClassifier.ts:166). Add an explicit null guard; runtime behavior is unchanged — a falsy provider already resolved to !entry -> false. * ci: re-run quality gates (vitest npm ci onnxruntime CDN flake) --------- Co-authored-by: Rouzbeh --- .../10420-antigravity-geoblock-resilience.md | 2 + open-sse/config/errorConfig.ts | 4 + open-sse/executors/antigravity.ts | 44 +++- .../executors/antigravityUpstreamError.ts | 25 ++- open-sse/handlers/chatCore.ts | 22 ++ open-sse/services/errorClassifier.ts | 74 +++++++ .../providers/[id]/test/oauthTestConfig.ts | 77 ++++++- src/app/api/providers/[id]/test/route.ts | 74 +++++-- src/sse/services/auth.ts | 1 + ...tigravity-competitive-prompt-strip.test.ts | 64 ++++++ .../antigravity-geoblock-resilience.test.ts | 189 ++++++++++++++++++ 11 files changed, 538 insertions(+), 38 deletions(-) create mode 100644 changelog.d/fixes/10420-antigravity-geoblock-resilience.md create mode 100644 tests/unit/antigravity-competitive-prompt-strip.test.ts create mode 100644 tests/unit/antigravity-geoblock-resilience.test.ts diff --git a/changelog.d/fixes/10420-antigravity-geoblock-resilience.md b/changelog.d/fixes/10420-antigravity-geoblock-resilience.md new file mode 100644 index 0000000000..cb465299b2 --- /dev/null +++ b/changelog.d/fixes/10420-antigravity-geoblock-resilience.md @@ -0,0 +1,2 @@ +- **fix(antigravity):** geo-blocked egress (Google "User location is not supported") is now classified (scoped to the Google AI surfaces that emit it: Cloud Code/Gemini Code Assist, Gemini API, Vertex), cached as a 24h per-account exclusion so routing continues with other accounts, and surfaced with an actionable message; the dashboard connection test now probes the real `streamGenerateContent` model surface instead of the non-geo-restricted OAuth userinfo endpoint ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh +- **fix(antigravity):** strip competing-agent identity sentences from system prompts (e.g. "You are a Claude agent, built on Anthropic's Claude Agent SDK.") that Antigravity flags and answers with 429 RESOURCE_EXHAUSTED (port of decolua/9router b566b20) ([#10420](https://github.com/diegosouzapw/OmniRoute/pull/10420)) — thanks @rqzbeh diff --git a/open-sse/config/errorConfig.ts b/open-sse/config/errorConfig.ts index 3081f53fb9..b326af7378 100644 --- a/open-sse/config/errorConfig.ts +++ b/open-sse/config/errorConfig.ts @@ -77,6 +77,10 @@ export const COOLDOWN_MS = { rateLimit: 2 * 60 * 1000, serviceUnavailable: 2 * 1000, authExpired: 2 * 60 * 1000, + // Google regional-availability refusal: nothing changes region-wise on the + // account, so re-probe only after a long window (or when the operator routes + // egress through a supported-region proxy). + geoBlocked: 24 * 60 * 60 * 1000, }; /** diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 7f949284c6..137b996920 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -339,6 +339,45 @@ function asRecord(value: unknown): Record | null { : null; } +/** + * Known competing-agent identity sentences that Antigravity's server-side + * filter flags, answering with a 429 RESOURCE_EXHAUSTED (port of + * decolua/9router b566b20, generalized). Only the identity sentence is + * removed — surrounding instruction text is untouched. + */ +const COMPETITIVE_AGENT_PROMPT_PATTERNS: RegExp[] = [ + /\byou are a claude agent\b[^\n]*/i, + /\bbuilt on anthropic's claude agent sdk\b[^\n]*/i, + /\byou are claude code\b[^\n]*/i, + /\byou are an ai assistant created by anthropic\b[^\n]*/i, +]; + +/** + * Strip competing-agent identity sentences from systemInstruction.parts. + * Returns the original reference when nothing matched (no allocation). + */ +export function stripCompetitiveAgentPrompts(systemInstruction: unknown): unknown { + const record = asRecord(systemInstruction); + const parts = Array.isArray(record?.parts) ? (record.parts as Array>) : []; + if (parts.length === 0) return systemInstruction; + + let changed = false; + const newParts = parts.map((part) => { + if (typeof part.text !== "string" || part.text.length === 0) return part; + let text = part.text; + for (const pattern of COMPETITIVE_AGENT_PROMPT_PATTERNS) { + const stripped = text.replace(pattern, "").replace(/\n{3,}/g, "\n\n").trimStart(); + if (stripped !== text) { + changed = true; + text = stripped; + } + } + return text === part.text ? part : { ...part, text }; + }); + + return changed ? { ...record, parts: newParts } : systemInstruction; +} + function getAntigravitySafetySettings(safetySettings: unknown): unknown[] | undefined { if (!Array.isArray(safetySettings)) return undefined; @@ -358,7 +397,10 @@ function sanitizeAntigravityGeminiRequest( } if (asRecord(request.systemInstruction)) { - clean.systemInstruction = request.systemInstruction; + // #10420: strip competing-agent identity sentences (e.g. "You are a + // Claude agent, built on Anthropic's Claude Agent SDK.") that Antigravity + // flags and answers with 429 RESOURCE_EXHAUSTED. + clean.systemInstruction = stripCompetitiveAgentPrompts(request.systemInstruction); } clean.generationConfig = asRecord(request.generationConfig) diff --git a/open-sse/executors/antigravityUpstreamError.ts b/open-sse/executors/antigravityUpstreamError.ts index 7b285c1ea0..074824ef17 100644 --- a/open-sse/executors/antigravityUpstreamError.ts +++ b/open-sse/executors/antigravityUpstreamError.ts @@ -8,12 +8,20 @@ * `buildErrorBody` instead so the client sees a proper error (hard rule #12). */ import { buildErrorBody } from "../utils/error.ts"; +import { isGeoBlockedError } from "../services/errorClassifier.ts"; -export function buildAntigravityUpstreamError( - status: number, - statusText: string, - rawBody: string -) { +// The dashboard "Test Connection" for antigravity only probes the OAuth userinfo +// endpoint (https://www.googleapis.com/oauth2/v1/userinfo), which is NOT +// geo-restricted — so a green tick does not prove the model path works. Spell +// this out in the geo-block message so operators stop chasing accounts. +const GEO_BLOCKED_HINT = + "The Cloud Code API is not offered from this server's current egress location " + + '("User location is not supported for the API use."). This is not an account ' + + "problem: the connection test only validates the Google OAuth token and does not " + + "call the model API. Route antigravity/agy egress through a proxy in a " + + "supported region (e.g. US/EU) or use a different provider."; + +export function buildAntigravityUpstreamError(status: number, statusText: string, rawBody: string) { let upstreamDetails: unknown; try { upstreamDetails = JSON.parse(rawBody); @@ -21,5 +29,12 @@ export function buildAntigravityUpstreamError( // upstream body is not JSON (e.g. HTML error page) — omit structured details } const suffix = statusText ? `: ${statusText}` : ""; + if (isGeoBlockedError(rawBody)) { + return buildErrorBody( + status, + `Antigravity upstream error (${status})${suffix}. ${GEO_BLOCKED_HINT}`, + upstreamDetails + ); + } return buildErrorBody(status, `Antigravity upstream error (${status})${suffix}`, upstreamDetails); } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index ce0656323d..cdd93178c9 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -3932,6 +3932,28 @@ export async function handleChatCore({ console.warn( `[provider] Node ${errorConnectionId} project routing error (${statusCode}) — not banning` ); + } else if (errorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED) { + // Google regional-availability refusal (e.g. "User location is not + // supported for the API use."). Account-independent and non-terminal: + // exclude the connection for the cooldown window so routing moves to + // other accounts instead of re-selecting this one on every request, + // and never mark it banned/expired. It becomes usable again once + // egress is routed through a supported-region proxy. + const geoCooldownMs = COOLDOWN_MS.geoBlocked ?? 24 * 60 * 60 * 1000; + await updateProviderConnection(errorConnectionId, { + lastErrorType: errorType, + lastError: message, + errorCode: statusCode, + }); + try { + const { setConnectionRateLimitUntil } = await import("@/lib/db/providers"); + setConnectionRateLimitUntil(errorConnectionId, Date.now() + geoCooldownMs); + } catch { + // DB write failure must never break the fallback loop + } + console.warn( + `[provider] Node ${errorConnectionId} geo-blocked (${statusCode}) — excluded for ${Math.ceil(geoCooldownMs / 1000)}s, trying other accounts` + ); } else if (errorType === PROVIDER_ERROR_TYPES.MODEL_NOT_FOUND) { // 404 — model/endpoint does not exist upstream. Lock the model so the // retry/backoff loop stops hammering the dead endpoint (which would diff --git a/open-sse/services/errorClassifier.ts b/open-sse/services/errorClassifier.ts index daa3bab657..43c1aa3079 100644 --- a/open-sse/services/errorClassifier.ts +++ b/open-sse/services/errorClassifier.ts @@ -79,6 +79,7 @@ export const PROVIDER_ERROR_TYPES = { EMPTY_CONTENT: "empty_content", MODEL_NOT_FOUND: "model_not_found", FINGERPRINT_REJECTION: "fingerprint_rejection", + GEO_BLOCKED: "geo_blocked", }; export const CONTEXT_OVERFLOW_SIGNALS = [ @@ -114,6 +115,61 @@ export function containsModelUnavailableMessage(errorMessage: string): boolean { return MODEL_NAMED_UNSUPPORTED_REGEX.test(String(errorMessage || "").toLowerCase()); } +// Google regional-availability rejection: the Cloud Code / Gemini Code Assist +// API is not offered from every country, and the upstream answers with a 400 +// FAILED_PRECONDITION like "User location is not supported for the API use." +// This is an ACCOUNT-INDEPENDENT, location-scoped refusal: every account on +// this server egresses from the same region, so retrying another credential +// cannot help — but routing egress through a proxy in a supported region can. +// Detected here so routing treats it as a non-terminal, cached-per-connection +// exclusion instead of a generic 400 (which would keep re-selecting the same +// account and surface a cryptic "upstream error (400)"). +const GEO_BLOCK_SIGNALS = [ + "user location is not supported", + "location is not supported", + "not supported for the api use", + "region is not supported", + "unsupported location", + "not available in your location", + "not available in your region", +]; + +export function isGeoBlockedError(errorMessage: string): boolean { + const lower = String(errorMessage || "").toLowerCase(); + return GEO_BLOCK_SIGNALS.some((signal) => lower.includes(signal)); +} + +// Providers whose upstream surface emits Google's regional-availability +// refusal (GEO_BLOCK_SIGNALS above): Cloud Code / Gemini Code Assist — the +// antigravity executor (antigravity, agy) — and the Gemini Developer API +// (generativelanguage.googleapis.com; gemini, vertex). The gate matters +// because classifyProviderError is shared across every provider: an unrelated +// upstream returning a lookalike "not available in your region" must NOT be +// classified as an egress-fixable geo block, or it would get the non-terminal +// 24h exclusion treatment instead of that provider's own (possibly terminal) +// path. +function isGeoBlockEligibleProvider(provider?: string | null): boolean { + const p = (provider || "").toLowerCase(); + if ( + p === "antigravity" || + p === "agy" || + p === "gemini" || + p === "gemini-cli" || + p === "vertex" + ) { + return true; + } + if (p.includes("cloudcode") || p.includes("cloud-code")) return true; + // Registry-driven fallback: any provider whose upstream surface is the Cloud + // Code API (executor/format "antigravity") or the Gemini API (format + // "gemini") stays eligible even when a new provider id is added later. + if (!provider) return false; + const entry = getRegistryEntry(provider); + if (!entry) return false; + const surface = `${entry.executor || ""} ${entry.format || ""}`.toLowerCase(); + return surface.includes("antigravity") || surface.includes("gemini"); +} + // Cloudflare 1010 "Access denied ... blocked based on your browser's signature" — // a fingerprint/browser-like rejection issued by the CDN in front of an upstream // (e.g. opencode.ai/zen/v1), carrying error_code 1010 or error_name @@ -242,6 +298,24 @@ export function classifyProviderError( } if (statusCode === 402) return PROVIDER_ERROR_TYPES.QUOTA_EXHAUSTED; + + // Google regional-availability refusal (400 FAILED_PRECONDITION "... location + // is not supported ..."), scoped to the Google AI surfaces that emit it + // (Cloud Code / Gemini Code Assist + Gemini Developer API — see + // isGeoBlockEligibleProvider). Account-independent: every credential egresses + // from the same server region, so fallback to another account cannot succeed + // — but the connection must be cached as excluded so routing does not + // re-select it on every request and surface a cryptic generic 400. + // Non-terminal, like PROJECT_ROUTE_ERROR: the account becomes usable again + // once egress is routed through a supported-region proxy. + if ( + (statusCode === 400 || statusCode === 403) && + isGeoBlockEligibleProvider(provider) && + isGeoBlockedError(bodyStr) + ) { + return PROVIDER_ERROR_TYPES.GEO_BLOCKED; + } + if (statusCode === 403 && isCloudflareFingerprintRejection(bodyStr)) { // Cloudflare 1010 / error_name "browser_signature_banned": the CDN in front of the // upstream (e.g. opencode.ai/zen/v1) rejected the CLIENT's TLS/UA signature, not the diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index 52f8713ee2..41a9aa3f20 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -1,4 +1,38 @@ import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab"; +import { ANTIGRAVITY_RUNTIME_BASE_URLS } from "@omniroute/open-sse/config/antigravityUpstream.ts"; +import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts"; +import { getAntigravityClientProfile } from "@omniroute/open-sse/services/antigravityClientProfile.ts"; + +// Real model-surface probe for antigravity/agy. The previous probe only hit the +// OAuth userinfo endpoint, which is NOT geo-restricted — so "Test Connection" +// stayed green while every model call failed with "User location is not +// supported for the API use." Probe the actual Cloud Code model endpoint +// (streamGenerateContent) with a minimal body: +// 2xx -> model path reachable (auth ok) +// 400 geo -> egress location blocked (auth ok — NOT an account problem) +// 401/403 -> token bad +// Mirrors AntigravityExecutor.buildUrl/buildHeaders so the probe exercises the +// exact same surface as real requests. +function buildAntigravityProbe( + connection: { providerSpecificData?: unknown }, + accessToken: string +) { + const profile = getAntigravityClientProfile(connection as never); + return { + url: `${ANTIGRAVITY_RUNTIME_BASE_URLS[0]}/v1internal:streamGenerateContent?alt=sse`, + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + ...getAntigravityContentHeaders(profile, accessToken), + }, + body: JSON.stringify({ + contents: [{ role: "user", parts: [{ text: "ping" }] }], + generationConfig: { maxOutputTokens: 1 }, + }), + }; +} const CLINE_OAUTH_TEST_CONFIG = { // Cline does not expose a stable lightweight auth probe. Validate token @@ -27,7 +61,34 @@ const XAI_CHAT_OAUTH_TEST_CONFIG = { // OAuth provider test endpoints. Extracted from route.ts (#7610) so adding a // provider entry doesn't grow the frozen route.ts file past its check-file-size // cap — this module carries no logic of its own beyond the GitLab URL builder. -export const OAUTH_TEST_CONFIG = { +// Probe request built at test time by provider-specific configs (e.g. +// antigravity), which need dynamic headers (client profile) the static fields +// cannot express. +export interface OAuthTestProbeRequest { + url: string; + method: string; + headers: Record; + body?: string; +} + +export interface OAuthTestConfigEntry { + url?: string; + method?: string; + authHeader?: string; + authPrefix?: string; + extraHeaders?: Record; + body?: string; + acceptStatuses?: number[]; + checkExpiry?: boolean; + refreshable?: boolean; + getUrl?: (connection: any) => string; + buildProbe?: ( + connection: any, + accessToken: string + ) => OAuthTestProbeRequest | Promise; +} + +export const OAUTH_TEST_CONFIG: Record = { claude: { // Claude doesn't have userinfo, we verify token exists and not expired checkExpiry: true, @@ -62,22 +123,18 @@ export const OAUTH_TEST_CONFIG = { refreshable: true, }, antigravity: { - url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", + // Real model-surface probe (see buildAntigravityProbe above): userinfo-only + // probing stayed green while the model API was geo-blocked. + buildProbe: buildAntigravityProbe, refreshable: true, }, // `agy` is a separate connection id that shares the Antigravity backend and the same // Google OAuth token lifecycle (tokenRefresh.ts routes it to refreshGoogleToken), but // it was missing here — so "Test Connection" fell through to "Provider test not // supported", recorded testStatus="error", and painted the home topology node red on a - // perfectly good account. Probe the same userinfo endpoint as antigravity. + // perfectly good account. Probe the same model surface as antigravity. agy: { - url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", + buildProbe: buildAntigravityProbe, refreshable: true, }, xai: XAI_CHAT_OAUTH_TEST_CONFIG, diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index 1201826c83..66cf2f6df9 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -25,6 +25,7 @@ import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotat import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth"; import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult"; import { OAUTH_TEST_CONFIG } from "./oauthTestConfig"; +import { isGeoBlockedError } from "@omniroute/open-sse/services/errorClassifier.ts"; // Bound the OAuth probe so a hung upstream can't block the connection-test queue // forever (#1449). Mirrors the 30s timeout the API-key path uses via validateProviderApiKey. @@ -438,20 +439,34 @@ export async function testOAuthConnection( // Call test endpoint try { - const headers = { - [config.authHeader]: `${config.authPrefix}${accessToken}`, - ...config.extraHeaders, - }; + // Provider-specific probe builders (e.g. antigravity) construct the full + // request — url/method/headers/body — because the real surface needs + // dynamic headers (client profile) that the static config cannot express. + const builtProbe = + typeof config.buildProbe === "function" + ? await config.buildProbe(connection, accessToken) + : null; + const headers = builtProbe + ? builtProbe.headers + : { + [config.authHeader]: `${config.authPrefix}${accessToken}`, + ...config.extraHeaders, + }; - const url = typeof config.getUrl === "function" ? config.getUrl(connection) : config.url; + const url = builtProbe + ? builtProbe.url + : typeof config.getUrl === "function" + ? config.getUrl(connection) + : config.url; const fetchInit: RequestInit = { - method: config.method, + method: builtProbe?.method ?? config.method, headers, signal: AbortSignal.timeout(timeoutMs), }; // Port of decolua/9router#347: providers like Codex must send a body so the // upstream returns 400 (auth ok) instead of 405/415. - if (config.body) fetchInit.body = config.body; + if (config.body && !builtProbe) fetchInit.body = config.body; + if (builtProbe?.body) fetchInit.body = builtProbe.body; const res = await fetch(url, fetchInit); // Port of decolua/9router#347: some providers (Codex) intentionally trigger a @@ -497,14 +512,20 @@ export async function testOAuthConnection( if (tokens) { // Retry with new token const retryInit: RequestInit = { - method: config.method, - headers: { - [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, - ...config.extraHeaders, - }, + method: builtProbe?.method ?? config.method, + headers: builtProbe + ? { + ...builtProbe.headers, + Authorization: `Bearer ${tokens.accessToken ?? accessToken}`, + } + : { + ...headers, + [config.authHeader]: `${config.authPrefix}${tokens.accessToken ?? accessToken}`, + }, signal: AbortSignal.timeout(timeoutMs), }; - if (config.body) retryInit.body = config.body; + if (builtProbe?.body) retryInit.body = builtProbe.body; + else if (config.body) retryInit.body = config.body; const retryRes = await fetch(url, retryInit); const retryAccepted = @@ -546,16 +567,25 @@ export async function testOAuthConnection( // #1444: read a 401/403 body so a deactivated account is labeled distinctly from a // revoked token. (The body is unread here for non-gitlab providers; the guard keeps - // it safe if it was already consumed.) + // it safe if it was already consumed.) antigravity/agy read any failure body so a + // geo-blocked egress location is labeled with an actionable message instead of a + // generic "API returned 400". const bodyText = - res.status === 401 || res.status === 403 ? await res.text().catch(() => "") : ""; - const error = isAccountDeactivatedMessage(bodyText) - ? "Account deactivated by the provider" - : res.status === 401 - ? "Token invalid or revoked" - : res.status === 403 - ? "Access denied" - : `API returned ${res.status}`; + res.status === 401 || + res.status === 403 || + connection.provider === "antigravity" || + connection.provider === "agy" + ? await res.text().catch(() => "") + : ""; + const error = isGeoBlockedError(bodyText) + ? "Egress location blocked by Google (User location is not supported). The Cloud Code API is not offered from this server's proxy exit region — route antigravity/agy through a proxy in a supported region (e.g. US/EU) or use a different provider. This is NOT an account problem." + : isAccountDeactivatedMessage(bodyText) + ? "Account deactivated by the provider" + : res.status === 401 + ? "Token invalid or revoked" + : res.status === 403 + ? "Access denied" + : `API returned ${res.status}`; return { valid: false, diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index f3ec47870a..da70522843 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -387,6 +387,7 @@ function resolveTerminalConnectionStatus( if (result.creditsExhausted || status === 402) return "credits_exhausted"; if ( providerErrorType === PROVIDER_ERROR_TYPES.PROJECT_ROUTE_ERROR || + providerErrorType === PROVIDER_ERROR_TYPES.GEO_BLOCKED || providerErrorType === PROVIDER_ERROR_TYPES.OAUTH_INVALID_TOKEN || // #1010: Cloudflare fingerprint rejection is the CDN refusing the CLIENT's // signature, not the account's credentials — never a terminal account state. diff --git a/tests/unit/antigravity-competitive-prompt-strip.test.ts b/tests/unit/antigravity-competitive-prompt-strip.test.ts new file mode 100644 index 0000000000..c7b6d3bf1e --- /dev/null +++ b/tests/unit/antigravity-competitive-prompt-strip.test.ts @@ -0,0 +1,64 @@ +/** + * Competitive system-prompt strip (port of decolua/9router b566b20, + * generalized): Antigravity's server-side filter flags system prompts + * advertising competing agents ("You are a Claude agent, built on + * Anthropic's Claude Agent SDK.") and answers with 429 RESOURCE_EXHAUSTED. + * The strip removes the identity sentences before dispatch. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { stripCompetitiveAgentPrompts } from "../../open-sse/executors/antigravity.ts"; + +test("strips the exact Claude Agent SDK identity line (9router b566b20 case)", () => { + const input = { + parts: [{ text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." }], + }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, ""); +}); + +test("keeps the instruction text that follows the identity sentence", () => { + const input = { + parts: [ + { + text: + "You are a Claude agent, built on Anthropic's Claude Agent SDK.\n" + + "Answer concisely and cite sources.", + }, + ], + }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, "Answer concisely and cite sources."); +}); + +test("strips 'You are Claude Code' and Anthropic-created assistant lines", () => { + const input = { parts: [{ text: "You are Claude Code, an agentic coding tool." }] }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, ""); +}); + +test("leaves ordinary system prompts untouched (same reference, no allocation)", () => { + const input = { parts: [{ text: "You are a helpful assistant. Be concise." }] }; + const out = stripCompetitiveAgentPrompts(input); + assert.strictEqual(out, input, "must return the original reference when nothing matched"); +}); + +test("only rewrites matching parts in a multi-part system instruction", () => { + const input = { + parts: [ + { text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." }, + { text: "Use the tools when available." }, + ], + }; + const out = stripCompetitiveAgentPrompts(input) as { parts: Array<{ text: string }> }; + assert.equal(out.parts[0].text, ""); + assert.equal(out.parts[1].text, "Use the tools when available."); +}); + +test("returns the input unchanged for non-systemInstruction shapes", () => { + const input = { contents: [{ role: "user", parts: [{ text: "hi" }] }] }; + assert.strictEqual(stripCompetitiveAgentPrompts(input), input); + assert.strictEqual(stripCompetitiveAgentPrompts(null), null); + assert.strictEqual(stripCompetitiveAgentPrompts(undefined), undefined); +}); diff --git a/tests/unit/antigravity-geoblock-resilience.test.ts b/tests/unit/antigravity-geoblock-resilience.test.ts new file mode 100644 index 0000000000..5f85e6f8a4 --- /dev/null +++ b/tests/unit/antigravity-geoblock-resilience.test.ts @@ -0,0 +1,189 @@ +/** + * Antigravity geo-block resilience (#PR): the Cloud Code / Gemini Code Assist + * model API refuses unsupported egress locations with 400 FAILED_PRECONDITION + * "User location is not supported for the API use." Previously this was + * classified as a generic 400 ("Antigravity upstream error (400)"), never + * excluded the account, and the dashboard connection test stayed green because + * it only probed the (non-geo-restricted) OAuth userinfo endpoint. + * + * Coverage: + * 1. classifyProviderError maps the geo refusal to GEO_BLOCKED (non-terminal), + * scoped to the Google AI surfaces that emit it (Cloud Code / Gemini API). + * 2. isGeoBlockedError recognizes the real Google wording and rejects lookalikes. + * 3. classify429 keeps Google's RESOURCE_EXHAUSTED-per-minute as rate_limited + * (established repo behavior — guards against future regressions here). + * 4. buildAntigravityUpstreamError surfaces an actionable geo message. + * 5. The dashboard probe for antigravity/agy hits the REAL model surface + * (streamGenerateContent), not userinfo. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { classifyProviderError, isGeoBlockedError, PROVIDER_ERROR_TYPES } = + await import("../../open-sse/services/errorClassifier.ts"); +const { classify429 } = await import("../../open-sse/services/antigravity429Engine.ts"); +const { buildAntigravityUpstreamError } = + await import("../../open-sse/executors/antigravityUpstreamError.ts"); +const { OAUTH_TEST_CONFIG } = + await import("../../src/app/api/providers/[id]/test/oauthTestConfig.ts"); + +const GEO_BODY = { + error: { + code: 400, + message: "User location is not supported for the API use.", + status: "FAILED_PRECONDITION", + }, +}; + +// ── 1. classifyProviderError ──────────────────────────────────────────────── + +test("geo refusal (400 FAILED_PRECONDITION) -> GEO_BLOCKED", () => { + assert.equal( + classifyProviderError(400, GEO_BODY, "antigravity"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +test("geo refusal with a raw text body -> GEO_BLOCKED", () => { + assert.equal( + classifyProviderError( + 400, + '{"error":{"status":"FAILED_PRECONDITION","message":"User location is not supported for the API use."}}', + "agy" + ), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +test("generic 400 (not geo) does NOT classify as GEO_BLOCKED", () => { + const result = classifyProviderError(400, { error: { message: "bad request" } }, "antigravity"); + assert.notEqual(result, PROVIDER_ERROR_TYPES.GEO_BLOCKED); +}); + +test("429 stays RATE_LIMITED (geo classification is status-scoped)", () => { + assert.equal( + classifyProviderError(429, GEO_BODY, "antigravity"), + PROVIDER_ERROR_TYPES.RATE_LIMITED + ); +}); + +// ── 1b. provider scoping of GEO_BLOCKED ────────────────────────────────────── + +test("geo refusal from Gemini API / Vertex providers -> GEO_BLOCKED", () => { + assert.equal( + classifyProviderError(400, GEO_BODY, "gemini"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); + assert.equal( + classifyProviderError(400, GEO_BODY, "vertex"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); + assert.equal( + classifyProviderError(400, GEO_BODY, "gemini-cli"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +test("geo-looking body from a non-Google provider does NOT classify as GEO_BLOCKED", () => { + // Falls through to the generic path (null for an unclassified 400): the 24h + // non-terminal exclusion is reserved for egress-fixable Google blocks — an + // unrelated provider's region wording may mean a permanent block. + assert.equal(classifyProviderError(400, GEO_BODY, "openai"), null); + assert.equal(classifyProviderError(400, GEO_BODY, "anthropic"), null); + assert.equal(classifyProviderError(400, GEO_BODY, "g4f-gemini"), null); + assert.equal( + classifyProviderError(400, "The API is not available in your region.", "mistral"), + null + ); +}); + +test("geo body with no provider does NOT classify as GEO_BLOCKED", () => { + assert.equal(classifyProviderError(400, GEO_BODY, undefined), null); +}); + +test("403 geo refusal stays GEO_BLOCKED for eligible providers", () => { + assert.equal( + classifyProviderError(403, GEO_BODY, "antigravity"), + PROVIDER_ERROR_TYPES.GEO_BLOCKED + ); +}); + +// ── 2. isGeoBlockedError ──────────────────────────────────────────────────── + +test("isGeoBlockedError matches Google wording variants", () => { + assert.equal(isGeoBlockedError("User location is not supported for the API use."), true); + assert.equal( + isGeoBlockedError('{"message":"This location is not supported for the API use"}'), + true + ); + assert.equal(isGeoBlockedError("The API is not available in your region."), true); +}); + +test("isGeoBlockedError rejects lookalike errors", () => { + assert.equal(isGeoBlockedError("Invalid API key"), false); + assert.equal(isGeoBlockedError("Quota exceeded for the API use"), false); + assert.equal(isGeoBlockedError("model not supported"), false); + assert.equal(isGeoBlockedError(""), false); +}); + +// ── 3. classify429: RESOURCE_EXHAUSTED stays rate_limited ─────────────────── + +test("classify429 keeps Google 'Resource has been exhausted (per minute)' as rate_limited", () => { + // Deliberate existing behavior (antigravity-429-quota-cooldown.test.ts): Google + // uses RESOURCE_EXHAUSTED for per-minute rate limits too, and the + // "(e.g. queries per minute limit was reached)" phrasing is the RPM case — + // short cooldown + same-auth retry, NOT a daily quota wall. + assert.equal( + classify429( + "RESOURCE_EXHAUSTED: Resource has been exhausted (e.g. queries per minute limit was reached)." + ), + "rate_limited" + ); + // A genuine quota-wall message still classifies as quota_exhausted. + assert.equal( + classify429("Individual quota reached. Contact your administrator."), + "quota_exhausted" + ); +}); + +// ── 4. buildAntigravityUpstreamError ──────────────────────────────────────── + +test("geo-blocked upstream error body carries an actionable hint", () => { + const body = buildAntigravityUpstreamError(400, "", JSON.stringify(GEO_BODY)) as { + error?: { message?: string }; + }; + assert.match(String(body.error?.message), /location is not supported/i); + assert.match(String(body.error?.message), /proxy in a supported region/i); + assert.match(String(body.error?.message), /connection test/i); +}); + +test("non-geo upstream error body is unchanged in shape", () => { + const body = buildAntigravityUpstreamError(500, "", '{"error":"boom"}') as { + error?: { message?: string }; + }; + assert.match(String(body.error?.message), /Antigravity upstream error \(500\)/); + assert.doesNotMatch(String(body.error?.message), /supported region/i); +}); + +// ── 5. dashboard probe hits the real model surface ────────────────────────── + +test("antigravity/agy connection test probes streamGenerateContent, not userinfo", async () => { + for (const provider of ["antigravity", "agy"]) { + const entry = OAUTH_TEST_CONFIG[provider]; + assert.ok(entry, `${provider} has a test config`); + assert.equal(typeof entry.buildProbe, "function", `${provider} uses a buildProbe`); + + const probe = await entry.buildProbe( + { providerSpecificData: { clientProfile: "ide" } }, + "sk-test-token" + ); + assert.match(probe.url, /v1internal:streamGenerateContent\?alt=sse/); + assert.equal(probe.method, "POST"); + assert.match(probe.headers.Authorization, /Bearer sk-test-token/); + assert.equal(probe.headers["Content-Type"], "application/json"); + assert.ok(probe.body, "probe carries a minimal generation body"); + const parsedBody = JSON.parse(probe.body as string); + assert.ok(Array.isArray(parsedBody.contents)); + assert.equal(parsedBody.generationConfig.maxOutputTokens, 1); + } +}); From df226e55f408f03ac060ac4925b7e5c59ea1d9c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rouzbeh=E2=80=A0?= <78313022+rqzbeh@users.noreply.github.com> Date: Sun, 16 Aug 2026 06:46:32 +0330 Subject: [PATCH 24/36] fix(usage): read Gemini usageMetadata out of the antigravity response envelope (#10430) * fix(usage): read Gemini usageMetadata out of the antigravity response envelope Port decolua/9router#59d858b: antigravity/gemini-cli wrap non-streaming payloads in { response: {...} }, so extractUsageFromResponse only saw the top-level usageMetadata and every non-streaming antigravity request logged zero usage (IN 0 | OUT 0) and zeroed usage-dashboard rows. Top-level metadata keeps priority; OpenAI/Claude branches untouched. * chore(changelog): fragment for #10430 antigravity usage envelope * ci: re-run dast-smoke (Build CLI bundle runner timeout flake) --------- Co-authored-by: Rouzbeh --- .../fixes/10430-antigravity-usage-envelope.md | 1 + open-sse/handlers/usageExtractor.ts | 13 +++--- tests/unit/usage-extractor.test.ts | 40 +++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 changelog.d/fixes/10430-antigravity-usage-envelope.md diff --git a/changelog.d/fixes/10430-antigravity-usage-envelope.md b/changelog.d/fixes/10430-antigravity-usage-envelope.md new file mode 100644 index 0000000000..645045e7ea --- /dev/null +++ b/changelog.d/fixes/10430-antigravity-usage-envelope.md @@ -0,0 +1 @@ +- **fix(usage):** read Gemini `usageMetadata` out of the antigravity `{ response: {...} }` envelope so non-streaming requests log real token usage instead of `IN 0 | OUT 0` (port of decolua/9router#59d858b) ([#10430](https://github.com/diegosouzapw/OmniRoute/pull/10430)) — thanks @rqzbeh diff --git a/open-sse/handlers/usageExtractor.ts b/open-sse/handlers/usageExtractor.ts index 8239c3feb9..313d1ff0e4 100644 --- a/open-sse/handlers/usageExtractor.ts +++ b/open-sse/handlers/usageExtractor.ts @@ -93,14 +93,17 @@ export function extractUsageFromResponse(responseBody, provider) { }; } - // Gemini format - if (responseBody.usageMetadata && typeof responseBody.usageMetadata === "object") { + // Gemini format. Antigravity / gemini-cli wrap the payload in + // { response: { ... } } — read the envelope so non-streaming requests do + // not silently log zero usage (port of decolua/9router#59d858b). + const usageMetadata = responseBody.usageMetadata || responseBody.response?.usageMetadata; + if (usageMetadata && typeof usageMetadata === "object") { // Gemini reports thoughts outside candidates. Fold them into completion so // every provider keeps reasoning as a subset of completion tokens. - const thoughts = responseBody.usageMetadata.thoughtsTokenCount || 0; + const thoughts = usageMetadata.thoughtsTokenCount || 0; return { - prompt_tokens: responseBody.usageMetadata.promptTokenCount || 0, - completion_tokens: (responseBody.usageMetadata.candidatesTokenCount || 0) + thoughts, + prompt_tokens: usageMetadata.promptTokenCount || 0, + completion_tokens: (usageMetadata.candidatesTokenCount || 0) + thoughts, reasoning_tokens: thoughts, }; } diff --git a/tests/unit/usage-extractor.test.ts b/tests/unit/usage-extractor.test.ts index 6fa9c7b100..851032690c 100644 --- a/tests/unit/usage-extractor.test.ts +++ b/tests/unit/usage-extractor.test.ts @@ -232,6 +232,46 @@ test("extractUsageFromResponse reads Gemini usageMetadata and thinking tokens", }); }); +test("extractUsageFromResponse reads Gemini usageMetadata from the antigravity response envelope", () => { + // Antigravity / gemini-cli wrap non-streaming payloads in { response: {...} } + // (port of decolua/9router#59d858b — previously logged zero usage). + const usage = extractUsageFromResponse( + { + response: { + usageMetadata: { + promptTokenCount: 42, + candidatesTokenCount: 13, + thoughtsTokenCount: 4, + cachedContentTokenCount: 7, + }, + }, + }, + "antigravity" + ); + + assert.deepEqual(usage, { + prompt_tokens: 42, + completion_tokens: 17, + reasoning_tokens: 4, + }); +}); + +test("extractUsageFromResponse prefers top-level usageMetadata over the envelope", () => { + const usage = extractUsageFromResponse( + { + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 2 }, + response: { usageMetadata: { promptTokenCount: 99, candidatesTokenCount: 99 } }, + }, + "gemini" + ); + + assert.deepEqual(usage, { + prompt_tokens: 1, + completion_tokens: 2, + reasoning_tokens: 0, + }); +}); + test("extractUsageFromResponse returns null when usage is missing", () => { const usage = extractUsageFromResponse( { From 579cae32b1407cd51ff41949883c54bcb8b9a581 Mon Sep 17 00:00:00 2001 From: SHANMUGAPRIYAN Date: Sun, 16 Aug 2026 08:46:36 +0530 Subject: [PATCH 25/36] fix(sse): buffer '' across SSE deltas leaked into content instead of being parsed as reasoning. Derive every proper prefix from THINK_OPEN itself so the lookahead list can never drift out of sync with the tag again. Covered by new unit tests for the partial-suffix lookahead and the split-delta buffering path. --- open-sse/utils/thinkTagParser.ts | 14 +++++++---- tests/unit/think-tag-parser.test.ts | 37 +++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/open-sse/utils/thinkTagParser.ts b/open-sse/utils/thinkTagParser.ts index bd75e28ee8..66ed5f7955 100644 --- a/open-sse/utils/thinkTagParser.ts +++ b/open-sse/utils/thinkTagParser.ts @@ -21,6 +21,15 @@ import { appendBoundedText, buildSyntheticChatChunk } from "./streamHelpers.ts"; const THINK_OPEN = ""; const THINK_CLOSE = ""; +/** + * Every proper prefix of `` ("<", " THINK_OPEN.slice(0, i + 1) +); + /** * Create the mutable streaming-parse context for one SSE stream. * `enabled` decides whether the caller should attempt think-tag parsing at @@ -52,10 +61,7 @@ export function initThinkState(isPassthroughMode: boolean, provider?: unknown, m * @returns {boolean} */ export function containsOrMayEndWithThinkOpenTag(value: string): boolean { - return ( - value.includes(THINK_OPEN) || - ["<", " value.endsWith(suffix)) - ); + return value.includes(THINK_OPEN) || THINK_OPEN_PARTIALS.some((suffix) => value.endsWith(suffix)); } /** diff --git a/tests/unit/think-tag-parser.test.ts b/tests/unit/think-tag-parser.test.ts index 2199e9356d..533fcb8d4a 100644 --- a/tests/unit/think-tag-parser.test.ts +++ b/tests/unit/think-tag-parser.test.ts @@ -1,8 +1,14 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { hasThinkTags, extractThinkTags, processStreamingThinkDelta, flushThinkBuffer } = - await import("../../open-sse/utils/thinkTagParser.ts"); +const { + hasThinkTags, + extractThinkTags, + processStreamingThinkDelta, + flushThinkBuffer, + containsOrMayEndWithThinkOpenTag, + applyThinkTag, +} = await import("../../open-sse/utils/thinkTagParser.ts"); test("hasThinkTags detects opening tags and ignores empty input", () => { assert.equal(hasThinkTags("before plan after"), true); @@ -60,6 +66,33 @@ test("processStreamingThinkDelta extracts content and reasoning across split tag }); }); +test("containsOrMayEndWithThinkOpenTag flags a chunk ending on any partial open tag", () => { + for (const partial of ["<", "' across deltas", () => { + const ctx = { enabled: true, active: false, insideThink: false, buffer: "" }; + + const opening: { content: unknown; reasoning_content?: string } = { content: "plananswer", + }; + assert.equal(applyThinkTag(ctx, rest), true); + assert.equal(rest.reasoning_content, "plan"); + assert.equal(rest.content, ""); + + assert.deepEqual(flushThinkBuffer(ctx), { + reasoningDelta: null, + contentDelta: "answer", + }); +}); + test("processStreamingThinkDelta keeps partial closing tags buffered while inside think", () => { const ctx = { insideThink: true, buffer: "" }; From 20fcb8d205be32a2314ca1448b8842c47a5c0047 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 16 Aug 2026 06:42:09 +0300 Subject: [PATCH 26/36] fix(affinity): evict the sticky session pin on a combo per-model timeout (#10016) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A combo target that stalls past comboTargetTimeoutMs is aborted by buildTargetTimeoutRunner, which swallows the resulting rejection behind its synthetic 524. Nothing marks the account unavailable — correctly, since a stall is not a quota/auth failure — so the #6219 eviction on the generic markAccountUnavailable -> shouldFallback path in chat.ts never ran. The session pin therefore survived its full TTL and every following request in that session was handed straight back to the account that had just stalled. Seen in production on combo "coding" [priority]: one codex account pinned for a 30-minute TTL, four consecutive requests, four 120s timeouts, "all targets exhausted" each time, while four sibling codex accounts stayed healthy and unused. Classify the abort reason (new dependency-free leaf comboAbortReasons.ts) and evict the connection-matched pin. Only a genuine per-model timeout evicts: a client disconnect or a hedge cancellation says nothing about account health, so those keep the pin and its prompt-cache locality. Eviction is best-effort and never breaks the dispatch path. The dispatch itself moves into a new seam, chatDispatch.ts, which merges the per-model abort signal into the outgoing request, runs executeChatWithBreaker, and owns the eviction on both the rejection and failed-result paths. Keeping that logic out of the frozen god-file leaves chat.ts one line SHORTER than before (1844 -> 1843). Co-authored-by: alexey.nazarov@softmg.ru Co-authored-by: fenix007 Co-authored-by: diegosouzapw --- open-sse/services/combo/comboAbortReasons.ts | 37 ++++ .../services/combo/targetTimeoutRunner.ts | 10 +- src/sse/handlers/chat.ts | 70 +++--- src/sse/handlers/chatDispatch.ts | 72 +++++++ src/sse/services/sessionAffinityPin.ts | 57 +++++ ...on-affinity-combo-timeout-eviction.test.ts | 202 ++++++++++++++++++ 6 files changed, 410 insertions(+), 38 deletions(-) create mode 100644 open-sse/services/combo/comboAbortReasons.ts create mode 100644 src/sse/handlers/chatDispatch.ts create mode 100644 tests/unit/session-affinity-combo-timeout-eviction.test.ts diff --git a/open-sse/services/combo/comboAbortReasons.ts b/open-sse/services/combo/comboAbortReasons.ts new file mode 100644 index 0000000000..0f44eff6a6 --- /dev/null +++ b/open-sse/services/combo/comboAbortReasons.ts @@ -0,0 +1,37 @@ +/** + * Shared abort reasons for combo target dispatch. + * + * `buildTargetTimeoutRunner` aborts a stalled target with `new Error(...)` as the + * abort reason, and hedged targets are cancelled with a different one. Consumers + * downstream (session-affinity eviction in src/sse/handlers/chat.ts) must be able + * to tell those two apart from an ordinary client disconnect: only the per-model + * TIMEOUT means "this account stalled", while a hedge cancellation means "a + * sibling target won" and says nothing about the account's health. + * + * Kept as a dependency-free leaf so src/** can import it without pulling in the + * combo dispatcher. + */ + +/** Abort reason used when a combo target exceeds `comboTargetTimeoutMs`. */ +export const COMBO_PER_MODEL_TIMEOUT_REASON = "combo-per-model-timeout"; + +/** Abort reason used when a hedged sibling target won the race. */ +export const COMBO_HEDGE_CANCELLED_REASON = "hedge-cancelled"; + +function abortReasonMessage(signal: AbortSignal): string { + const reason: unknown = signal.reason; + if (typeof reason === "string") return reason; + if (reason && typeof reason === "object" && typeof (reason as Error).message === "string") { + return (reason as Error).message; + } + return ""; +} + +/** + * True only when `signal` was aborted by the combo per-model timeout. A client + * disconnect, a hedge cancellation, or a non-aborted signal all return false. + */ +export function isComboPerModelTimeoutAbort(signal: AbortSignal | null | undefined): boolean { + if (!signal?.aborted) return false; + return abortReasonMessage(signal) === COMBO_PER_MODEL_TIMEOUT_REASON; +} diff --git a/open-sse/services/combo/targetTimeoutRunner.ts b/open-sse/services/combo/targetTimeoutRunner.ts index 6264cb7ff7..09a80559b2 100644 --- a/open-sse/services/combo/targetTimeoutRunner.ts +++ b/open-sse/services/combo/targetTimeoutRunner.ts @@ -10,6 +10,10 @@ * See _tasks/superpowers/plans/2026-07-03-blocoJ-combo-hotpath-decomposition.md (Task 1). */ import { buildErrorBody, errorResponse, sanitizeErrorMessage } from "../../utils/error.ts"; +import { + COMBO_HEDGE_CANCELLED_REASON, + COMBO_PER_MODEL_TIMEOUT_REASON, +} from "./comboAbortReasons.ts"; import type { HandleSingleModel, SingleModelTarget, ComboLogger } from "./types.ts"; /** Stable internal classification for OmniRoute's own combo per-target timer. */ @@ -46,7 +50,7 @@ export function buildTargetTimeoutRunner(deps: { "COMBO", `Model ${modelStr} exceeded ${comboTargetTimeoutMs}ms timeout — falling back` ); - timeoutController.abort(new Error("combo-per-model-timeout")); + timeoutController.abort(new Error(COMBO_PER_MODEL_TIMEOUT_REASON)); // HTTP 504 (not proprietary 524): this is OmniRoute's own per-target timer. // Typed as combo_target_timeout so request-scoped classification can keep the // connection eligible for fallback instead of treating it like Cloudflare 524 @@ -75,10 +79,10 @@ export function buildTargetTimeoutRunner(deps: { let onParentHedgeAbort: (() => void) | null = null; if (parentHedgeSignal) { if (parentHedgeSignal.aborted) { - timeoutController.abort(new Error("hedge-cancelled")); + timeoutController.abort(new Error(COMBO_HEDGE_CANCELLED_REASON)); } else { onParentHedgeAbort = () => { - timeoutController.abort(new Error("hedge-cancelled")); + timeoutController.abort(new Error(COMBO_HEDGE_CANCELLED_REASON)); }; parentHedgeSignal.addEventListener("abort", onParentHedgeAbort, { once: true }); } diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index b0defec2a7..2e0d426722 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -62,6 +62,7 @@ import { evictSessionAccountAffinityForConnection, getSessionAccountAffinity, } from "@/lib/db/sessionAccountAffinity"; +import { dispatchChatWithAffinityEviction } from "./chatDispatch"; import { getCachedSettings, getCombosCacheVersion } from "@/lib/db/readCache"; import { getCombos } from "@/lib/db/combos"; import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings"; @@ -1535,42 +1536,41 @@ async function handleSingleModelChat( const proxyStartTime = Date.now(); // 4. Execute chat via core after breaker gate checks (with optional TLS tracking) if (telemetry) telemetry.startPhase("connect"); - const dispatchClientRawRequest = resolveDispatchClientRawRequest( - clientRawRequest, - runtimeOptions.modelAbortSignal - ); - let execution: Awaited>; + let execution: Awaited>; try { - execution = await executeChatWithBreaker({ - bypassCircuitBreaker: forceLiveComboTest || hasForcedConnection, - breaker, - body: requestBody, - provider, - model: effectiveModel, - refreshedCredentials, - proxyInfo, - appliedProxySink, - log, - clientRawRequest: dispatchClientRawRequest, - credentials, - apiKeyInfo, - userAgent, - comboName, - comboStrategy, - isCombo, - comboStepId: runtimeOptions.comboStepId ?? null, - comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null, - extendedContext, - modelApiFormat: apiFormat, - modelTargetFormat: targetFormat, - providerProfile, - cachedSettings: runtimeOptions.cachedSettings, - skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, - correlationId: runtimeOptions?.correlationId ?? null, - modelPinned: runtimeOptions?.modelPinned ?? false, - routingComboId: runtimeOptions?.routingComboId ?? null, - sessionAffinityKey: runtimeOptions.sessionAffinityKey ?? null, - }); + execution = await dispatchChatWithAffinityEviction( + { + bypassCircuitBreaker: forceLiveComboTest || hasForcedConnection, + breaker, + body: requestBody, + provider, + model: effectiveModel, + refreshedCredentials, + proxyInfo, + appliedProxySink, + log, + clientRawRequest, + credentials, + apiKeyInfo, + userAgent, + comboName, + comboStrategy, + isCombo, + comboStepId: runtimeOptions.comboStepId ?? null, + comboExecutionKey: runtimeOptions.comboExecutionKey ?? runtimeOptions.comboStepId ?? null, + extendedContext, + modelApiFormat: apiFormat, + modelTargetFormat: targetFormat, + providerProfile, + cachedSettings: runtimeOptions.cachedSettings, + skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, + correlationId: runtimeOptions?.correlationId ?? null, + modelPinned: runtimeOptions?.modelPinned ?? false, + routingComboId: runtimeOptions?.routingComboId ?? null, + sessionAffinityKey: runtimeOptions.sessionAffinityKey ?? null, + }, + runtimeOptions + ); } catch (error) { releaseOAuthSession(); throw error; diff --git a/src/sse/handlers/chatDispatch.ts b/src/sse/handlers/chatDispatch.ts new file mode 100644 index 0000000000..87b8676807 --- /dev/null +++ b/src/sse/handlers/chatDispatch.ts @@ -0,0 +1,72 @@ +/** + * Dispatch seam between chat.ts and executeChatWithBreaker, extracted so the + * frozen god-file `chat.ts` does not grow (check:file-size). + * + * Its only job beyond forwarding the call is the #6219 follow-up: when a combo + * per-model timeout abandons the account this session is pinned to, drop the + * pin. See `evictSessionAffinityOnComboTimeout` for why the existing #6219 + * eviction never covers this path. + */ + +import { executeChatWithBreaker } from "./chatHelpers"; +import { resolveDispatchClientRawRequest } from "./chat/clientRawRequest.ts"; +import { evictSessionAffinityOnComboTimeout } from "../services/sessionAffinityPin"; + +/** The dispatch arguments chat.ts already assembles, plus what the eviction reads. */ +type DispatchArgs = { + provider: string; + credentials: { connectionId: string }; + clientRawRequest: any; + [key: string]: unknown; +}; + +/** Runtime fields this seam consults; the rest of runtimeOptions is ignored. */ +type DispatchRuntimeOptions = { + sessionAffinityKey?: string | null; + modelAbortSignal?: AbortSignal | null; +}; + +/** + * Merge the per-model abort signal into the outgoing request, run the upstream + * dispatch, and evict the sticky session pin when a combo per-model timeout + * abandons it. + * + * The abort surfaces two ways: as a rejection out of `executeChatWithBreaker` + * (the common case — `buildTargetTimeoutRunner` then swallows it behind its + * synthetic 524), or as a failed result when an executor catches the abort + * itself. Both are covered. The eviction is a no-op unless this dispatch was + * aborted by the per-model timeout specifically. + */ +export async function dispatchChatWithAffinityEviction( + args: DispatchArgs, + runtimeOptions: DispatchRuntimeOptions +): Promise>> { + const evict = () => + evictSessionAffinityOnComboTimeout({ + sessionKey: runtimeOptions.sessionAffinityKey, + provider: args.provider, + connectionId: args.credentials?.connectionId, + modelAbortSignal: runtimeOptions.modelAbortSignal, + }); + + let dispatched: Awaited>; + try { + dispatched = await executeChatWithBreaker({ + ...args, + clientRawRequest: resolveDispatchClientRawRequest( + args.clientRawRequest, + runtimeOptions.modelAbortSignal + ), + }); + } catch (dispatchErr) { + evict(); + throw dispatchErr; + } + + // A resource-pressure short-circuit (no upstream dispatch happened) is not a + // combo per-model timeout — never treat it as one. + if ("localResourcePressureResult" in dispatched) return dispatched; + + if (!dispatched.result?.success) evict(); + return dispatched; +} diff --git a/src/sse/services/sessionAffinityPin.ts b/src/sse/services/sessionAffinityPin.ts index 5cb1447408..a2055e0fa5 100644 --- a/src/sse/services/sessionAffinityPin.ts +++ b/src/sse/services/sessionAffinityPin.ts @@ -31,6 +31,7 @@ import { upsertSessionAccountAffinity, touchSessionAccountAffinity, deleteSessionAccountAffinity, + evictSessionAccountAffinityForConnection, } from "@/lib/db/sessionAccountAffinity"; import { touchConnectionLastUsed } from "@/lib/db/providers"; import { isModelExcludedByConnection } from "@/domain/connectionModelRules"; @@ -39,6 +40,7 @@ import { isAccountUnavailable, isModelLocked, } from "@omniroute/open-sse/services/accountFallback.ts"; +import { isComboPerModelTimeoutAbort } from "@omniroute/open-sse/services/combo/comboAbortReasons.ts"; import * as log from "../utils/logger"; /** Minimal structural view of a provider connection this module reads. */ @@ -139,6 +141,61 @@ export async function selectSessionAffinityConnection abortedWith(new Error(abortReasons.COMBO_PER_MODEL_TIMEOUT_REASON)); + +test.beforeEach(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("evicts the pin when the combo per-model timeout abandons the pinned account", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: timedOutSignal(), + }); + + assert.equal(evicted, true, "a timed-out pinned account must lose its pin"); + assert.equal( + affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL), + null, + "the next request must be free to pick another account" + ); +}); + +test("leaves the pin intact on a client disconnect", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: abortedWith(new Error("request_signal_aborted")), + }); + + assert.equal(evicted, false, "a client hanging up says nothing about account health"); + assert.equal( + affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, + STALLED, + "pin must survive so the session keeps its prompt-cache locality" + ); +}); + +test("leaves the pin intact when a hedged sibling cancelled this target", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: abortedWith(new Error(abortReasons.COMBO_HEDGE_CANCELLED_REASON)), + }); + + assert.equal(evicted, false, "losing a hedge race is not an account failure"); + assert.equal(affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, STALLED); +}); + +test("leaves the pin intact when the dispatch was never aborted", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, STALLED, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: new AbortController().signal, + }); + + assert.equal(evicted, false); + assert.equal(affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, STALLED); +}); + +test("never evicts a pin that points at a different (healthy) account", () => { + affinityDb.upsertSessionAccountAffinity(SESSION, PROVIDER, HEALTHY, Date.now(), TTL); + + const evicted = pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: timedOutSignal(), + }); + + assert.equal(evicted, false, "connection-matched guard must hold"); + assert.equal(affinityDb.getSessionAccountAffinity(SESSION, PROVIDER, TTL)?.connectionId, HEALTHY); +}); + +test("no-ops without a session key or connection id", () => { + const signal = timedOutSignal(); + assert.equal( + pin.evictSessionAffinityOnComboTimeout({ + sessionKey: null, + provider: PROVIDER, + connectionId: STALLED, + modelAbortSignal: signal, + }), + false + ); + assert.equal( + pin.evictSessionAffinityOnComboTimeout({ + sessionKey: SESSION, + provider: PROVIDER, + connectionId: null, + modelAbortSignal: signal, + }), + false + ); +}); + +test("isComboPerModelTimeoutAbort accepts a bare string abort reason", () => { + assert.equal( + abortReasons.isComboPerModelTimeoutAbort( + abortedWith(abortReasons.COMBO_PER_MODEL_TIMEOUT_REASON) + ), + true + ); + assert.equal(abortReasons.isComboPerModelTimeoutAbort(null), false); +}); + +test("the combo timeout runner aborts with the shared reason constant", () => { + const src = fs.readFileSync( + new URL("../../open-sse/services/combo/targetTimeoutRunner.ts", import.meta.url), + "utf8" + ); + assert.match( + src, + /timeoutController\.abort\(new Error\(COMBO_PER_MODEL_TIMEOUT_REASON\)\)/, + "the runner must use the constant the eviction predicate matches on" + ); +}); + +test("chat.ts routes its upstream dispatch through the eviction-aware seam", () => { + const src = fs.readFileSync(new URL("../../src/sse/handlers/chat.ts", import.meta.url), "utf8"); + assert.match( + src, + /dispatchChatWithAffinityEviction\(/, + "chat.ts must dispatch through the seam that owns the eviction" + ); + assert.doesNotMatch( + src, + /await executeChatWithBreaker\(/, + "chat.ts must not bypass the seam by calling executeChatWithBreaker directly" + ); +}); + +test("the dispatch seam evicts when a dispatch is abandoned", () => { + const src = fs.readFileSync( + new URL("../../src/sse/handlers/chatDispatch.ts", import.meta.url), + "utf8" + ); + assert.match( + src, + /evictSessionAffinityOnComboTimeout\(/, + "chatDispatch.ts must call the eviction" + ); +}); From d46e8d72c9bc8f9e3f3a6c9a84938ab31a729522 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:42:14 -0600 Subject: [PATCH 27/36] feat(cli): refuse ephemeral container auto-config writes (#10057) * feat(cli): refuse ephemeral container auto-config writes Detect containerized OmniRoute and block CLI/API config writes into throwaway homes unless a bind mount or explicit opt-in is present, and honor compose host-profile CLI_CONFIG_HOME mounts outside the container home. Co-authored-by: Cursor * chore(changelog): name fragment for #10057 Co-authored-by: Cursor --------- Co-authored-by: Cursor Co-authored-by: yansigit Co-authored-by: diegosouzapw --- .env.example | 10 + bin/cli/commands/config.mjs | 16 ++ bin/cli/commands/configure.mjs | 18 +- bin/cli/commands/setup-aider.mjs | 26 +- bin/cli/commands/setup-claude.mjs | 13 + bin/cli/commands/setup-cline.mjs | 39 ++- bin/cli/commands/setup-codex.mjs | 13 + bin/cli/commands/setup-continue.mjs | 27 +- bin/cli/commands/setup-crush.mjs | 33 ++- bin/cli/commands/setup-cursor.mjs | 20 +- bin/cli/commands/setup-goose.mjs | 28 ++- bin/cli/commands/setup-kilo.mjs | 55 +++- bin/cli/commands/setup-open-code.mjs | 12 + bin/cli/commands/setup-opencode.mjs | 14 ++ bin/cli/commands/setup-qwen.mjs | 15 ++ bin/cli/commands/setup-roo.mjs | 55 +++- bin/cli/utils/config-home-guard.mjs | 122 +++++++++ .../10057-docker-aware-auto-config.md | 1 + docs/guides/DOCKER_GUIDE.md | 56 +++++ docs/reference/CLI-TOOLS.md | 86 ++++--- docs/reference/ENVIRONMENT.md | 18 +- skills/cli-setup/SKILL.md | 2 + skills/cli-skill-collector/SKILL.md | 2 + .../cli-code/components/DefaultToolCard.tsx | 10 +- src/app/api/cli-tools/apply/route.ts | 19 ++ .../guide-settings/[toolId]/route.ts | 30 +++ src/lib/api/cliConfigWriteGuard.ts | 33 +++ src/shared/services/cliRuntime.ts | 45 +++- src/shared/utils/containerConfigGuard.ts | 60 +++++ src/shared/utils/containerEnv.ts | 144 +++++++++++ tests/unit/cli-config-home-container.test.ts | 149 +++++++++++ tests/unit/cli-container-write-guard.test.ts | 125 ++++++++++ ...cli-setup-container-guard-coverage.test.ts | 76 ++++++ .../cli-tools-apply-container-422.test.ts | 164 ++++++++++++ tests/unit/container-env-detect.test.ts | 235 ++++++++++++++++++ 35 files changed, 1682 insertions(+), 89 deletions(-) create mode 100644 bin/cli/utils/config-home-guard.mjs create mode 100644 changelog.d/features/10057-docker-aware-auto-config.md create mode 100644 src/lib/api/cliConfigWriteGuard.ts create mode 100644 src/shared/utils/containerConfigGuard.ts create mode 100644 src/shared/utils/containerEnv.ts create mode 100644 tests/unit/cli-config-home-container.test.ts create mode 100644 tests/unit/cli-container-write-guard.test.ts create mode 100644 tests/unit/cli-setup-container-guard-coverage.test.ts create mode 100644 tests/unit/cli-tools-apply-container-422.test.ts create mode 100644 tests/unit/container-env-detect.test.ts diff --git a/.env.example b/.env.example index 0881c1b8e4..2545510595 100644 --- a/.env.example +++ b/.env.example @@ -714,6 +714,16 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Allow OmniRoute to write CLI config files (token refresh, etc.). # CLI_ALLOW_CONFIG_WRITES=true +# Force container detection on (1/true) or off (0/false). Leave unset for auto-detect +# via /.dockerenv, /run/.containerenv, cgroup markers, or KUBERNETES_SERVICE_HOST. +# Used by: src/shared/utils/containerEnv.ts — gates ephemeral-home CLI config writes. +# OMNIROUTE_CONTAINER=1 + +# Allow CLI-tool config writes into an unmounted container path anyway (default off). +# Prefer host-side `omniroute configure` / Remote Mode, or a bind-mounted CLI_CONFIG_HOME. +# CLI equivalent: --allow-container-write. Used by: src/shared/utils/containerEnv.ts +# OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true + # Auto-sync CLI profile files after provider model discovery changes. OPT-IN, default OFF for # both. When enabled, writes only the tool's profile files (~/.codex/*.config.toml or # ~/.claude/profiles//settings.json); never changes the active/default config. Both also diff --git a/bin/cli/commands/config.mjs b/bin/cli/commands/config.mjs index 348d59969f..6376ba9217 100644 --- a/bin/cli/commands/config.mjs +++ b/bin/cli/commands/config.mjs @@ -5,6 +5,7 @@ import fs from "node:fs"; import { fileURLToPath } from "node:url"; import { resolveDataDir } from "../data-dir.mjs"; import { registerContexts } from "./contexts.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; function ensureBackup(configPath) { if (!fs.existsSync(configPath)) return; @@ -87,6 +88,13 @@ async function runConfigSetCommand(toolId, opts = {}) { return 1; } + const guard = await guardHostConfigTarget(result.configPath, { + toolLabel: toolId, + hostCommand: `omniroute config set ${toolId}`, + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + }); + if (guard !== 0) return guard; + const nonInteractive = opts.nonInteractive || opts.yes; if (!nonInteractive) { @@ -271,6 +279,10 @@ export function registerConfig(program) { .option("--model ", "Model identifier (where applicable)") .option("--non-interactive", "Do not prompt for confirmation") .option("--yes", "Skip confirmation prompt") + .option( + "--allow-container-write", + "Write the config even when OmniRoute runs in a container and the target is not mounted from the host" + ) .action(async (tool, opts, cmd) => { const globalOpts = cmd.parent.optsWithGlobals(); const exitCode = await runConfigSetCommand(tool, { @@ -306,6 +318,10 @@ export function registerConfig(program) { .option("--model ", "Model identifier") .option("--non-interactive", "Do not prompt for confirmation") .option("--yes", "Skip confirmation prompt") + .option( + "--allow-container-write", + "Write the config even when OmniRoute runs in a container and the target is not mounted from the host" + ) .action(async (opts, cmd) => { const globalOpts = cmd.parent.optsWithGlobals(); const exitCode = await runConfigSetCommand("opencode", { diff --git a/bin/cli/commands/configure.mjs b/bin/cli/commands/configure.mjs index 2a92cd25a2..c84846148f 100644 --- a/bin/cli/commands/configure.mjs +++ b/bin/cli/commands/configure.mjs @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs"; import { apiFetch } from "../api.mjs"; import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs"; import { t } from "../i18n.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; /** * `omniroute configure ` — interactive provider+model picker that writes a @@ -75,6 +76,12 @@ function buildCodexProfile(modelId, ctx) { async function configureCodex(modelId, ctxWindow, opts) { const codexHome = opts.codexHome || path.join(os.homedir(), ".codex"); + const guard = await guardHostConfigTarget(codexHome, { + toolLabel: "Codex", + hostCommand: "omniroute configure codex", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + }); + if (guard !== 0) return guard; if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true }); const profile = opts.name || profileNameFromModel(modelId); const filePath = path.join(codexHome, `${profile}.config.toml`); @@ -86,6 +93,7 @@ async function configureCodex(modelId, ctxWindow, opts) { printInfo(`Use it: codex --profile ${profile}`); printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block"); printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md)."); + return 0; } export async function runConfigureCommand(cli, opts = {}, cmd) { @@ -130,7 +138,9 @@ export async function runConfigureCommand(cli, opts = {}, cmd) { } const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id)))); const candidates = inProvider.length ? inProvider : ids; - printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`); + printInfo( + `Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}` + ); chosenId = await prompt.ask("Model id"); } finally { prompt.close(); @@ -149,7 +159,7 @@ export async function runConfigureCommand(cli, opts = {}, cmd) { const ctxWindow = contextWindowOf(entry); if (target === "codex") { - await configureCodex(chosenId, ctxWindow, opts); + return await configureCodex(chosenId, ctxWindow, opts); } return 0; } @@ -173,6 +183,10 @@ export function registerConfigure(program) { .option("--model ", "Model id (skips the interactive model prompt)") .option("--name ", "Profile name to write (default: derived from model)") .option("--codex-home ", "Codex home dir (default: ~/.codex)") + .option( + "--allow-container-write", + "Write the config even when OmniRoute runs in a container and the target is not mounted from the host" + ) .action(async (cli, opts, cmd) => { const code = await runConfigureCommand(cli, opts, cmd); if (code !== 0) process.exit(code); diff --git a/bin/cli/commands/setup-aider.mjs b/bin/cli/commands/setup-aider.mjs index f9c0b5c8bb..f3002533ed 100644 --- a/bin/cli/commands/setup-aider.mjs +++ b/bin/cli/commands/setup-aider.mjs @@ -13,6 +13,7 @@ import { join } from "node:path"; import os from "node:os"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; function stripToRoot(url) { const s = String(url || "").replace(/\/+$/, ""); @@ -25,7 +26,9 @@ export function resolveAiderTarget(opts = {}) { if (opts.remote) root = stripToRoot(opts.remote); else { try { - root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl); + root = stripToRoot( + resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl + ); } catch { /* none */ } @@ -78,7 +81,7 @@ async function fetchModelIds(apiBase, apiKey) { const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); if (!res.ok) return []; const body = await res.json(); - const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); } catch { return []; @@ -88,7 +91,16 @@ async function fetchModelIds(apiBase, apiKey) { export async function runSetupAiderCommand(opts = {}) { const { apiBase, apiKey } = resolveAiderTarget(opts); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); - const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml"); + const configPath = + opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml"); + + const guard = await guardHostConfigTarget(configPath, { + toolLabel: "Aider", + hostCommand: "omniroute setup-aider", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)"); printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`); @@ -107,7 +119,9 @@ export async function runSetupAiderCommand(opts = {}) { } } if (!model) { - printError("A model is required. Pass --model (the openai/ prefix is added automatically)."); + printError( + "A model is required. Pass --model (the openai/ prefix is added automatically)." + ); return 2; } @@ -139,6 +153,10 @@ export function registerSetupAider(program) { .option("--config-path ", ".aider.conf.yml path (default: ~/.aider.conf.yml)") .option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const code = await runSetupAiderCommand(opts); if (code !== 0) process.exit(code); diff --git a/bin/cli/commands/setup-claude.mjs b/bin/cli/commands/setup-claude.mjs index 600a33d8bb..6567824490 100644 --- a/bin/cli/commands/setup-claude.mjs +++ b/bin/cli/commands/setup-claude.mjs @@ -20,6 +20,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import os from "node:os"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; import { categoriseModel, isCodexCompatibleTextModel, @@ -147,6 +148,14 @@ export async function runSetupClaudeCommand(opts = {}) { printHeading("OmniRoute → Claude Code profile generator"); printInfo(`Connecting to ${baseUrl} …`); + const guard = await guardHostConfigTarget(profilesRoot, { + toolLabel: "Claude Code", + hostCommand: "omniroute setup-claude", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; + // ── Fetch model catalog ─────────────────────────────────────────────────── let models; try { @@ -220,6 +229,10 @@ export function registerSetupClaude(program) { "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)" ) .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const exitCode = await runSetupClaudeCommand(opts); if (exitCode !== 0) process.exit(exitCode); diff --git a/bin/cli/commands/setup-cline.mjs b/bin/cli/commands/setup-cline.mjs index 1a76273855..aadbdb41c4 100644 --- a/bin/cli/commands/setup-cline.mjs +++ b/bin/cli/commands/setup-cline.mjs @@ -16,6 +16,7 @@ import { join } from "node:path"; import os from "node:os"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; function stripToRoot(url) { let s = String(url || "").replace(/\/+$/, ""); @@ -28,11 +29,14 @@ export function resolveClineTarget(opts = {}) { if (opts.remote) baseUrl = stripToRoot(opts.remote); else { try { - baseUrl = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl); + baseUrl = stripToRoot( + resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl + ); } catch { /* none */ } - if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; + if (!baseUrl) + baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`; } let apiKey = opts.apiKey ?? opts["api-key"]; if (!apiKey) { @@ -81,7 +85,7 @@ async function fetchModelIds(baseUrl, apiKey) { const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); if (!res.ok) return []; const body = await res.json(); - const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); } catch { return []; @@ -93,6 +97,14 @@ export async function runSetupClineCommand(opts = {}) { const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data"); + const guard = await guardHostConfigTarget(clineDir, { + toolLabel: "Cline", + hostCommand: "omniroute setup-cline", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; + printHeading("OmniRoute → Cline (OpenAI-compatible)"); printInfo(`Server: ${baseUrl}`); @@ -122,7 +134,18 @@ export async function runSetupClineCommand(opts = {}) { if (dryRun) { console.log(`\n── [dry-run] ${gsPath} ──`); - console.log(JSON.stringify({ actModeApiProvider: globalState.actModeApiProvider, planModeApiProvider: globalState.planModeApiProvider, openAiBaseUrl: globalState.openAiBaseUrl, openAiModelId: globalState.openAiModelId }, null, 2)); + console.log( + JSON.stringify( + { + actModeApiProvider: globalState.actModeApiProvider, + planModeApiProvider: globalState.planModeApiProvider, + openAiBaseUrl: globalState.openAiBaseUrl, + openAiModelId: globalState.openAiModelId, + }, + null, + 2 + ) + ); console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`); } else { if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true }); @@ -133,7 +156,9 @@ export async function runSetupClineCommand(opts = {}) { } // The VS Code extension uses opaque globalStorage — can't be file-written. - printInfo("\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):"); + printInfo( + "\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):" + ); printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`); printInfo(` API Key: `); printInfo(` Model: ${model}`); @@ -153,6 +178,10 @@ export function registerSetupCline(program) { .option("--cline-dir ", "Cline data dir (default: ~/.cline/data)") .option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const code = await runSetupClineCommand(opts); if (code !== 0) process.exit(code); diff --git a/bin/cli/commands/setup-codex.mjs b/bin/cli/commands/setup-codex.mjs index b820d37e2b..1cdf4afd8b 100644 --- a/bin/cli/commands/setup-codex.mjs +++ b/bin/cli/commands/setup-codex.mjs @@ -16,6 +16,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import os from "node:os"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; import { t } from "../i18n.mjs"; // ── Model categorisation ────────────────────────────────────────────────────── @@ -306,6 +307,14 @@ export async function runSetupCodexCommand(opts = {}) { const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null; printHeading(`OmniRoute → Codex CLI profile generator`); + + const guard = await guardHostConfigTarget(codexHome, { + toolLabel: "Codex", + hostCommand: "omniroute setup-codex", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; printInfo(`Connecting to ${baseUrl} …`); // ── Fetch model catalog ─────────────────────────────────────────────────── @@ -380,6 +389,10 @@ export function registerSetupCodex(program) { "Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)" ) .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const exitCode = await runSetupCodexCommand(opts); if (exitCode !== 0) process.exit(exitCode); diff --git a/bin/cli/commands/setup-continue.mjs b/bin/cli/commands/setup-continue.mjs index 6320d8a9c4..3e7eb3cac6 100644 --- a/bin/cli/commands/setup-continue.mjs +++ b/bin/cli/commands/setup-continue.mjs @@ -14,6 +14,7 @@ import os from "node:os"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; import { categoriseModel } from "./setup-codex.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}"; @@ -92,7 +93,7 @@ async function fetchModelIds(apiBase, apiKey) { }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); - const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); } catch (e) { throw new Error(`Could not fetch models: ${e.message}`); @@ -102,8 +103,22 @@ async function fetchModelIds(apiBase, apiKey) { export async function runSetupContinueCommand(opts = {}) { const { apiBase, apiKey } = resolveContinueTarget(opts); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); - const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null; - const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml"); + const only = opts.only + ? opts.only + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : null; + const configPath = + opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml"); + + const guard = await guardHostConfigTarget(configPath, { + toolLabel: "Continue", + hostCommand: "omniroute setup-continue", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; printHeading("OmniRoute → Continue (config.yaml)"); printInfo(`apiBase: ${apiBase}`); @@ -150,7 +165,7 @@ export async function runSetupContinueCommand(opts = {}) { printInfo("\nProvide the key (config.yaml references it, not stores it):"); printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)"); printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env"); - printInfo("Run: cn -p \"reply OK\""); + printInfo('Run: cn -p "reply OK"'); return 0; } @@ -166,6 +181,10 @@ export function registerSetupContinue(program) { .option("--only ", "Comma-separated substrings — keep only matching model IDs") .option("--config-path ", "config.yaml path (default: ~/.continue/config.yaml)") .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const code = await runSetupContinueCommand(opts); if (code !== 0) process.exit(code); diff --git a/bin/cli/commands/setup-crush.mjs b/bin/cli/commands/setup-crush.mjs index fe6ceafc71..475126d207 100644 --- a/bin/cli/commands/setup-crush.mjs +++ b/bin/cli/commands/setup-crush.mjs @@ -13,6 +13,7 @@ import os from "node:os"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; import { categoriseModel } from "./setup-codex.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; const API_KEY_REF = "$OMNIROUTE_API_KEY"; @@ -87,15 +88,29 @@ async function fetchModelIds(baseUrl, apiKey) { }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); - const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); } export async function runSetupCrushCommand(opts = {}) { const { baseUrl, apiKey } = resolveCrushTarget(opts); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); - const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null; - const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json"); + const only = opts.only + ? opts.only + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : null; + const configPath = + opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json"); + + const guard = await guardHostConfigTarget(configPath, { + toolLabel: "Crush", + hostCommand: "omniroute setup-crush", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; printHeading("OmniRoute → Crush (openai-compat)"); printInfo(`base_url: ${baseUrl}`); @@ -120,13 +135,17 @@ export async function runSetupCrushCommand(opts = {}) { if (dryRun) { console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out)); - printInfo(`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`); + printInfo( + `[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}` + ); return 0; } mkdirSync(join(configPath, ".."), { recursive: true }); writeFileSync(configPath, out, "utf8"); printSuccess(`Wrote ${configPath} (${provider.models.length} models under providers.omniroute)`); - printInfo("Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."); + printInfo( + "Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..." + ); printInfo("Then run: crush"); return 0; } @@ -141,6 +160,10 @@ export function registerSetupCrush(program) { .option("--only ", "Comma-separated substrings — keep only matching model IDs") .option("--config-path ", "crush.json path (default: ~/.config/crush/crush.json)") .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const code = await runSetupCrushCommand(opts); if (code !== 0) process.exit(code); diff --git a/bin/cli/commands/setup-cursor.mjs b/bin/cli/commands/setup-cursor.mjs index c23b5accdd..45dedfd43b 100644 --- a/bin/cli/commands/setup-cursor.mjs +++ b/bin/cli/commands/setup-cursor.mjs @@ -10,6 +10,7 @@ import { printHeading, printInfo, printSuccess } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; +import { isContainerRuntime } from "../utils/config-home-guard.mjs"; function ensureV1(url) { const s = String(url || "").replace(/\/+$/, ""); @@ -71,7 +72,7 @@ async function fetchModelIds(apiBase, apiKey) { }); if (!res.ok) return []; const body = await res.json(); - const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); } catch { return []; @@ -84,19 +85,32 @@ export async function runSetupCursorCommand(opts = {}) { printInfo(`Server: ${apiBase}`); let models = []; - const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null; + const only = opts.only + ? opts.only + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : null; const ids = await fetchModelIds(apiBase, apiKey); models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids; console.log("\n" + buildCursorInstructions({ apiBase, models })); printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque)."); + if (await isContainerRuntime()) { + printInfo( + "Note: this ran inside a container, so the base URL above is the container's own view. " + + "Use the address the host reaches OmniRoute on (e.g. the published port) in Cursor's settings." + ); + } return 0; } export function registerSetupCursor(program) { program .command("setup-cursor") - .description("Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)") + .description( + "Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)" + ) .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") diff --git a/bin/cli/commands/setup-goose.mjs b/bin/cli/commands/setup-goose.mjs index 789c71dcf7..d977078028 100644 --- a/bin/cli/commands/setup-goose.mjs +++ b/bin/cli/commands/setup-goose.mjs @@ -14,6 +14,7 @@ import { join } from "node:path"; import os from "node:os"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; function stripToRoot(url) { const s = String(url || "").replace(/\/+$/, ""); @@ -26,7 +27,9 @@ export function resolveGooseTarget(opts = {}) { if (opts.remote) root = stripToRoot(opts.remote); else { try { - root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl); + root = stripToRoot( + resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl + ); } catch { /* none */ } @@ -80,7 +83,7 @@ async function fetchModelIds(host, apiKey) { const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); if (!res.ok) return []; const body = await res.json(); - const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); } catch { return []; @@ -90,7 +93,16 @@ async function fetchModelIds(host, apiKey) { export async function runSetupGooseCommand(opts = {}) { const { host, apiKey } = resolveGooseTarget(opts); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); - const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml"); + const configPath = + opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml"); + + const guard = await guardHostConfigTarget(configPath, { + toolLabel: "Goose", + hostCommand: "omniroute setup-goose", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; printHeading("OmniRoute → Goose (openai-compatible)"); printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`); @@ -128,14 +140,16 @@ export async function runSetupGooseCommand(opts = {}) { printInfo("\nProvide the key (Goose reads it from the env / OS keyring):"); console.log(buildGooseEnvRecipe({ host, model })); - printInfo("Then run: goose session (or: goose run -t \"reply OK\")"); + printInfo('Then run: goose session (or: goose run -t "reply OK")'); return 0; } export function registerSetupGoose(program) { program .command("setup-goose") - .description("Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe") + .description( + "Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe" + ) .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") @@ -143,6 +157,10 @@ export function registerSetupGoose(program) { .option("--config-path ", "config.yaml path (default: ~/.config/goose/config.yaml)") .option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const code = await runSetupGooseCommand(opts); if (code !== 0) process.exit(code); diff --git a/bin/cli/commands/setup-kilo.mjs b/bin/cli/commands/setup-kilo.mjs index c42e4d8246..ada147fe67 100644 --- a/bin/cli/commands/setup-kilo.mjs +++ b/bin/cli/commands/setup-kilo.mjs @@ -14,6 +14,7 @@ import { join } from "node:path"; import os from "node:os"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; /** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */ function ensureV1(url) { @@ -61,7 +62,11 @@ export function buildKiloAuth(existing, { apiKey, baseUrl, model }) { /** Merge the kilocode.* keys into VS Code settings.json (extension surface). */ export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) { const s = { ...(existing || {}) }; - s["kilocode.customProvider"] = { name: "OmniRoute", baseURL: baseUrl, apiKey: apiKey || "sk_omniroute" }; + s["kilocode.customProvider"] = { + name: "OmniRoute", + baseURL: baseUrl, + apiKey: apiKey || "sk_omniroute", + }; s["kilocode.defaultModel"] = model; return s; } @@ -85,7 +90,7 @@ async function fetchModelIds(root, apiKey) { }); if (!res.ok) return []; const body = await res.json(); - const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); } catch { return []; @@ -95,9 +100,22 @@ async function fetchModelIds(root, apiKey) { export async function runSetupKiloCommand(opts = {}) { const { baseUrl, apiKey } = resolveKiloTarget(opts); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); - const authPath = opts.authPath ?? opts["auth-path"] ?? join(os.homedir(), ".local", "share", "kilo", "auth.json"); + const authPath = + opts.authPath ?? + opts["auth-path"] ?? + join(os.homedir(), ".local", "share", "kilo", "auth.json"); + + const guard = await guardHostConfigTarget(authPath, { + toolLabel: "Kilo Code", + hostCommand: "omniroute setup-kilo", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; const vscodePath = - opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json"); + opts.vscodeSettings ?? + opts["vscode-settings"] ?? + join(os.homedir(), ".config", "Code", "User", "settings.json"); printHeading("OmniRoute → Kilo Code (OpenAI-compatible)"); printInfo(`Server: ${baseUrl}`); @@ -116,7 +134,9 @@ export async function runSetupKiloCommand(opts = {}) { } } if (!model) { - printError("A model is required. Pass --model (Kilo's extension has no model auto-discovery)."); + printError( + "A model is required. Pass --model (Kilo's extension has no model auto-discovery)." + ); return 2; } @@ -132,12 +152,19 @@ export async function runSetupKiloCommand(opts = {}) { console.log(`\n── [dry-run] ${authPath} ──`); console.log( JSON.stringify( - { "openai-compatible": { ...auth["openai-compatible"], apiKey: apiKey ? "set" : "sk_omniroute" } }, + { + "openai-compatible": { + ...auth["openai-compatible"], + apiKey: apiKey ? "set" : "sk_omniroute", + }, + }, null, 2 ) ); - console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`); + console.log( + `\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}` + ); } else { mkdirSync(join(authPath, ".."), { recursive: true }); writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8"); @@ -167,10 +194,20 @@ export function registerSetupKilo(program) { .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--model ", "Model id for Kilo (required unless picked interactively)") - .option("--auth-path ", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)") - .option("--vscode-settings ", "VS Code settings.json (default: ~/.config/Code/User/settings.json)") + .option( + "--auth-path ", + "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)" + ) + .option( + "--vscode-settings ", + "VS Code settings.json (default: ~/.config/Code/User/settings.json)" + ) .option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const code = await runSetupKiloCommand(opts); if (code !== 0) process.exit(code); diff --git a/bin/cli/commands/setup-open-code.mjs b/bin/cli/commands/setup-open-code.mjs index 60f08158c2..1837bfe1d3 100644 --- a/bin/cli/commands/setup-open-code.mjs +++ b/bin/cli/commands/setup-open-code.mjs @@ -30,6 +30,7 @@ import os from "node:os"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { t } from "../i18n.mjs"; import { resolveActiveContext } from "../contexts.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -316,6 +317,13 @@ export async function runSetupOpenCodeCommand(opts = {}) { printInfo(`OpenCode config dir: ${opencodeConfigDir}`); printInfo(`OpenCode data dir: ${opencodeDataDir}`); + const guard = await guardHostConfigTarget(opencodeConfigDir, { + toolLabel: "OpenCode", + hostCommand: "omniroute setup opencode", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + }); + if (guard !== 0) return { exitCode: guard }; + // 1. Resolve bundled plugin let pluginInfo; try { @@ -420,6 +428,10 @@ export function registerSetupOpenCode(setupCommand) { false ) .option("--non-interactive", "Do not prompt; skip the auth login step", false) + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts, cmd) => { // The parent `setup` command uses cmd.optsWithGlobals(); we mirror // that here so global flags (--json, --base-url, --api-key) still diff --git a/bin/cli/commands/setup-opencode.mjs b/bin/cli/commands/setup-opencode.mjs index f2d5889f22..f6039fb1a9 100644 --- a/bin/cli/commands/setup-opencode.mjs +++ b/bin/cli/commands/setup-opencode.mjs @@ -14,6 +14,7 @@ import { basename, dirname } from "node:path"; import { applyEdits, modify, parse, printParseErrorCode } from "jsonc-parser"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; const ENV_KEY_REF = "{env:OMNIROUTE_API_KEY}"; const JSON_FORMATTING_OPTIONS = { insertSpaces: true, tabSize: 2 }; @@ -119,6 +120,15 @@ export async function runSetupOpencodeCommand(opts = {}) { const { resolveOpencodeConfigPath } = await import("../../../src/shared/services/opencodeConfigPath.ts"); configPath = resolveOpencodeConfigPath(); + + const guard = await guardHostConfigTarget(configPath, { + toolLabel: "OpenCode", + hostCommand: "omniroute setup-opencode", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; + raw = await generateOpencodeConfig({ baseUrl, apiKey, @@ -163,6 +173,10 @@ export function registerSetupOpencode(program) { .option("--model ", "Set the default top-level model (omniroute/)") .option("--only ", "Comma-separated substrings — keep only matching model IDs") .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const code = await runSetupOpencodeCommand(opts); if (code !== 0) process.exit(code); diff --git a/bin/cli/commands/setup-qwen.mjs b/bin/cli/commands/setup-qwen.mjs index ee5ec6d18d..18f45f603f 100644 --- a/bin/cli/commands/setup-qwen.mjs +++ b/bin/cli/commands/setup-qwen.mjs @@ -18,6 +18,7 @@ import { normalizeQwenCodeBaseUrl, } from "../../../src/shared/services/qwenCodeConfig.ts"; import { resolveActiveContext } from "../contexts.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; import { createPrompt, printError, printHeading, printInfo, printSuccess } from "../io.mjs"; /** Resolve base URL and key from flags, active context, then local defaults. */ @@ -102,6 +103,16 @@ export async function runSetupQwenCommand(opts = {}) { printHeading("OmniRoute → Qwen Code (OpenAI-compatible)"); printInfo(`baseUrl: ${baseUrl}`); + for (const target of [settingsPath, envPath]) { + const guard = await guardHostConfigTarget(target, { + toolLabel: "Qwen Code", + hostCommand: "omniroute setup-qwen", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; + } + let model = String(opts.model || "").trim(); if (!model && !opts.yes) { const modelIds = await fetchModelIds(baseUrl, apiKey); @@ -159,6 +170,10 @@ export function registerSetupQwen(program) { .option("--env-path ", "Qwen Code .env path") .option("--yes", "Non-interactive; requires --model") .option("--dry-run", "Print settings without writing files or secrets") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const code = await runSetupQwenCommand(opts); if (code !== 0) process.exitCode = code; diff --git a/bin/cli/commands/setup-roo.mjs b/bin/cli/commands/setup-roo.mjs index bc6a00a670..4e5fc3e731 100644 --- a/bin/cli/commands/setup-roo.mjs +++ b/bin/cli/commands/setup-roo.mjs @@ -16,6 +16,7 @@ import { join } from "node:path"; import os from "node:os"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { resolveActiveContext } from "../contexts.mjs"; +import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; function ensureV1(url) { const s = String(url || "").replace(/\/+$/, ""); @@ -89,7 +90,7 @@ async function fetchModelIds(baseUrl, apiKey) { }); if (!res.ok) return []; const body = await res.json(); - const list = Array.isArray(body) ? body : body.data ?? body.models ?? []; + const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); } catch { return []; @@ -99,9 +100,20 @@ async function fetchModelIds(baseUrl, apiKey) { export async function runSetupRooCommand(opts = {}) { const { baseUrl, apiKey } = resolveRooTarget(opts); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); - const importPath = opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json"); + const importPath = + opts.importPath ?? opts["import-path"] ?? join(os.homedir(), ".omniroute", "roo-settings.json"); + + const guard = await guardHostConfigTarget(importPath, { + toolLabel: "Roo Code", + hostCommand: "omniroute setup-roo", + allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun, + }); + if (guard !== 0) return guard; const vscodePath = - opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json"); + opts.vscodeSettings ?? + opts["vscode-settings"] ?? + join(os.homedir(), ".config", "Code", "User", "settings.json"); printHeading("OmniRoute → Roo Code (OpenAI-compatible)"); printInfo(`Server: ${baseUrl}`); @@ -130,8 +142,27 @@ export async function runSetupRooCommand(opts = {}) { if (dryRun) { console.log(`\n── [dry-run] ${importPath} ──`); - console.log(JSON.stringify({ ...importDoc, providerProfiles: { ...importDoc.providerProfiles, apiConfigs: { OmniRoute: { ...importDoc.providerProfiles.apiConfigs.OmniRoute, openAiApiKey: apiKey ? "set" : "sk_omniroute" } } } }, null, 2)); - console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}`); + console.log( + JSON.stringify( + { + ...importDoc, + providerProfiles: { + ...importDoc.providerProfiles, + apiConfigs: { + OmniRoute: { + ...importDoc.providerProfiles.apiConfigs.OmniRoute, + openAiApiKey: apiKey ? "set" : "sk_omniroute", + }, + }, + }, + }, + null, + 2 + ) + ); + console.log( + `\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would set roo-cline.autoImportSettingsPath)" : "(skipped — file absent)"}` + ); } else { mkdirSync(join(importPath, ".."), { recursive: true }); writeFileSync(importPath, JSON.stringify(importDoc, null, 2) + "\n", "utf8"); @@ -161,10 +192,20 @@ export function registerSetupRoo(program) { .option("--remote ", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--api-key ", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--model ", "Model id for Roo (required unless picked interactively)") - .option("--import-path ", "Roo import JSON path (default: ~/.omniroute/roo-settings.json)") - .option("--vscode-settings ", "VS Code settings.json (default: ~/.config/Code/User/settings.json)") + .option( + "--import-path ", + "Roo import JSON path (default: ~/.omniroute/roo-settings.json)" + ) + .option( + "--vscode-settings ", + "VS Code settings.json (default: ~/.config/Code/User/settings.json)" + ) .option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--dry-run", "Print what would be written without touching the filesystem") + .option( + "--allow-container-write", + "Write even when the target is inside a container and not mounted from the host" + ) .action(async (opts) => { const code = await runSetupRooCommand(opts); if (code !== 0) process.exit(code); diff --git a/bin/cli/utils/config-home-guard.mjs b/bin/cli/utils/config-home-guard.mjs new file mode 100644 index 0000000000..8d5cabae1f --- /dev/null +++ b/bin/cli/utils/config-home-guard.mjs @@ -0,0 +1,122 @@ +import { printError, printInfo } from "../io.mjs"; + +/** + * Container guard for CLI-tool config writes. + * + * `omniroute setup-*` writes to `~/.codex`, `~/.claude`, ... — paths that only + * mean something on the operator's host. Run the same command inside the + * OmniRoute container and the write "succeeds" into an ephemeral layer that no + * host CLI ever reads and that disappears with the container. This guard turns + * that silent no-op into an actionable refusal. + * + * Bind-mounted targets (the compose `host` profile) are allowed through: the + * mount is the operator's explicit statement that the path reaches the host. + */ + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); + +/** Exit code for a refused write — matches the CLI's usage-error convention. */ +export const CONTAINER_WRITE_EXIT_CODE = 2; + +function envAllowsContainerWrite(env = process.env) { + return TRUE_VALUES.has( + String(env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE ?? "") + .trim() + .toLowerCase() + ); +} + +/** + * Classify a pending config write. + * + * @param {string} targetPath Absolute path the command is about to write. + * @param {{ + * toolLabel?: string, + * hostCommand?: string, + * allowContainerWrite?: boolean, + * dryRun?: boolean, + * env?: NodeJS.ProcessEnv, + * deps?: object, + * }} options + * @returns {Promise<{ok: boolean, message?: string, warning?: string}>} + */ +export async function assertHostConfigTarget(targetPath, options = {}) { + const { + toolLabel, + hostCommand, + allowContainerWrite = false, + dryRun = false, + env = process.env, + deps, + } = options; + + let describeContainerTarget; + let buildContainerWriteRefusal; + let CLI_OVERRIDE_HINT; + try { + // `.ts` extension is required so the published package (which ships only TS + // source, resolved through tsx) can load these. See #2509. + ({ describeContainerTarget } = await import("../../../src/shared/utils/containerEnv.ts")); + ({ buildContainerWriteRefusal, CLI_OVERRIDE_HINT } = + await import("../../../src/shared/utils/containerConfigGuard.ts")); + } catch { + // Fail open: a guard that cannot load must not block a legitimate host run. + return { ok: true }; + } + + const info = describeContainerTarget(targetPath, deps); + if (!info.ephemeral) return { ok: true }; + + if (dryRun) { + return { + ok: true, + warning: + `[dry-run] ${targetPath} is inside the container and is not mounted from the host — ` + + `a real run would be refused. See --allow-container-write.`, + }; + } + + if (allowContainerWrite || envAllowsContainerWrite(env)) { + return { + ok: true, + warning: + `Writing to ${targetPath} inside the container as requested — this file is lost when ` + + `the container is recreated and host CLIs will not see it.`, + }; + } + + return { + ok: false, + message: buildContainerWriteRefusal(targetPath, { + toolLabel, + hostCommand, + overrideHint: CLI_OVERRIDE_HINT, + }), + }; +} + +/** + * Container check for commands that write nothing but still print host-oriented + * instructions (setup-cursor). Fails closed to `false` so a broken import never + * turns into a spurious warning. + */ +export async function isContainerRuntime(deps) { + try { + const { isRunningInContainer } = await import("../../../src/shared/utils/containerEnv.ts"); + return isRunningInContainer(deps); + } catch { + return false; + } +} + +/** + * Guard + report. Returns 0 to continue, or CONTAINER_WRITE_EXIT_CODE when the + * caller should abort and return that code. + */ +export async function guardHostConfigTarget(targetPath, options = {}) { + const result = await assertHostConfigTarget(targetPath, options); + if (result.warning) printInfo(result.warning); + if (result.ok) return 0; + printError(result.message); + return CONTAINER_WRITE_EXIT_CODE; +} diff --git a/changelog.d/features/10057-docker-aware-auto-config.md b/changelog.d/features/10057-docker-aware-auto-config.md new file mode 100644 index 0000000000..d8718c5801 --- /dev/null +++ b/changelog.d/features/10057-docker-aware-auto-config.md @@ -0,0 +1 @@ +- **feat(cli):** container-aware auto-config — `setup-*`, `omniroute configure`, `omniroute config set` and the CLI-tool config APIs now refuse to write into a containerised OmniRoute's ephemeral home (CLI exits `2`, API returns `422` with `containerEphemeralTarget`) and point at the host-CLI or bind-mount setup instead; `--allow-container-write` / `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` opt back in. Also fixes `CLI_CONFIG_HOME` so the Compose `host` profile's `/host-home` bind mounts are honoured instead of silently falling back to the container home. (#10057) diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 6740a1958e..523d87bf77 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -14,6 +14,7 @@ lastUpdated: 2026-06-28 - [With Environment File](#with-environment-file) - [Docker Compose](#docker-compose) - [Available Profiles](#available-profiles) +- [Configuring host CLI tools when OmniRoute runs in Docker](#configuring-host-cli-tools-when-omniroute-runs-in-docker) - [Redis Sidecar](#redis-sidecar) - [Production Compose](#production-compose) - [Dockerfile Stages](#dockerfile-stages) @@ -82,6 +83,61 @@ OmniRoute ships four Compose profiles. Pick the one that matches your environmen > Multiple profiles can be combined: `docker compose --profile cli --profile cliproxyapi up -d`. +## Configuring host CLI tools when OmniRoute runs in Docker + +`omniroute setup-codex`, `setup-claude`, `config set ` and the dashboard's +**Save config** button all write files like `~/.codex/*.config.toml`. Those paths +only mean something on the machine where the CLI actually runs. Run them inside +the container and the write lands in the container's own home (`/home/node` — +the image runs `USER node`), where no host CLI will ever read it and where it is +discarded the moment the container is recreated. + +OmniRoute detects this and refuses the write with instructions instead of +reporting a success you cannot use: the CLI exits `2`, and the API answers `422` +with `containerEphemeralTarget: true`. + +### Recommended: run the CLI on the host, OmniRoute in Docker + +The container serves the API; the CLI configures your host tools. + +```bash +docker compose --profile base up -d + +npm install -g omniroute +omniroute connect http://localhost:20128 # point the CLI at the container +omniroute setup-codex # writes the real ~/.codex on your host +``` + +This is the right choice when Codex, Claude Code, Cursor or similar run on your +laptop — which is the usual setup. + +### Alternative: bind-mount the host config dirs (`host` profile) + +If you want the container itself to write your host config, mount the +directories in and point `CLI_CONFIG_HOME` at the mount root. The `host` profile +already does this: + +```yaml +environment: + - CLI_CONFIG_HOME=/host-home + - CLI_ALLOW_CONFIG_WRITES=true +volumes: + - ~/.codex:/host-home/.codex:rw + - ~/.claude:/host-home/.claude:rw +``` + +A bind mount is what makes the path trustworthy: OmniRoute reads +`/proc/self/mountinfo` and allows writes to mounted paths (and to directories +whose children are mounts, which is exactly the `/host-home` shape above) while +still refusing unmounted ones. + +### Escape hatch: configure the container's own CLIs + +When the CLIs genuinely live inside the container (the `cli` profile), the write +is intentional. Pass `--allow-container-write` to any `setup-*` command, or set +`OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` for the server. The write proceeds +with a warning that it will not survive the container. + ## Redis Sidecar OmniRoute relies on Redis to back the distributed rate limiter and shared cache. The `redis` service is **always defined** in `docker-compose.yml` (it has no profile gate) and starts alongside any other profile. diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index 81cbd0c4d7..fe3f65d1e0 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -69,6 +69,20 @@ with the right env injected and write no config at all. > local vs remote, and which tools want a `/v1` suffix — lives in > **[CLI Integrations](../guides/CLI-INTEGRATIONS.md)**. +### Running these inside a container + +A `setup-*` command executed inside the OmniRoute container writes into the +container's own home, which no host CLI reads and which disappears with the +container. OmniRoute detects that and exits `2` with instructions rather than +writing. Two supported ways forward — install the CLI on the host and +`omniroute connect` to the container, or bind-mount the config dirs and set +`CLI_CONFIG_HOME` (the compose `host` profile). Every `setup-*` command, plus +`omniroute configure` and `omniroute config set`, accepts +`--allow-container-write` when configuring the container's own CLIs is what you +actually meant; `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true` does the same for +the server. See +[Docker Guide → Configuring host CLI tools](../guides/DOCKER_GUIDE.md#configuring-host-cli-tools-when-omniroute-runs-in-docker). + --- ## Source of Truth @@ -94,33 +108,33 @@ Entries with `baseUrlSupport: "none"` are **not shown** in the dashboard pages All tools that appear in `/dashboard/cli-code`. Those with `baseUrlSupport: none` are wired through MITM or a manual guide instead of a custom base URL: -| id | name | vendor | baseUrlSupport | configType | acpSpawnable | -|----|------|--------|---------------|-----------|-------------| -| claude | Claude Code | Anthropic | full | env | true | -| codex | OpenAI Codex CLI | OpenAI | full | custom | true | -| cline | Cline | OSS (ex-Claude Dev) | full | custom | true | -| kilo | Kilo Code | Kilo-Org | full | custom | false | -| roo | Roo Code | Roo (OSS) | full | guide | false | -| continue | Continue | continue.dev | full | guide | false | -| aider | Aider | OSS (P. Gauthier) | full | guide | true | -| forge | ForgeCode | Antinomy HQ | full | custom | true | -| jcode | jcode | 1jehuang (OSS) | full | custom | false | -| deepseek-tui | DeepSeek TUI | Hunter Bown (OSS) | full | custom | false | -| codewhale | CodeWhale | Hmbown (OSS) | full | custom | false | -| opencode | OpenCode | Anomaly (ex-SST) | full | guide | true | -| droid | Factory Droid | Factory AI | partial | guide | false | -| copilot | GitHub Copilot CLI | GitHub/MS | full | custom | false | -| cursor-cli | Cursor CLI | Anysphere | partial | guide | true | -| smelt | Smelt | leonardcser (OSS) | full | custom | false | -| pi | Pi (pi-coding-agent) | M. Zechner (OSS) | full | custom | false | -| grok-build | Grok Build | xAI | full | custom | false | -| crush | Crush | OSS (Charm) | full | custom | false | -| qwen | Qwen Code | Alibaba | full | guide | true | -| cursor | Cursor | Anysphere | none | guide | false | -| antigravity | Antigravity | Google | none | mitm | false | -| hermes | Hermes | Nous Research | none | guide | false | -| kiro | Kiro AI | Amazon | none | mitm | false | -| custom | Custom CLI | — | full | custom-builder | false | +| id | name | vendor | baseUrlSupport | configType | acpSpawnable | +| ------------ | -------------------- | ------------------- | -------------- | -------------- | ------------ | +| claude | Claude Code | Anthropic | full | env | true | +| codex | OpenAI Codex CLI | OpenAI | full | custom | true | +| cline | Cline | OSS (ex-Claude Dev) | full | custom | true | +| kilo | Kilo Code | Kilo-Org | full | custom | false | +| roo | Roo Code | Roo (OSS) | full | guide | false | +| continue | Continue | continue.dev | full | guide | false | +| aider | Aider | OSS (P. Gauthier) | full | guide | true | +| forge | ForgeCode | Antinomy HQ | full | custom | true | +| jcode | jcode | 1jehuang (OSS) | full | custom | false | +| deepseek-tui | DeepSeek TUI | Hunter Bown (OSS) | full | custom | false | +| codewhale | CodeWhale | Hmbown (OSS) | full | custom | false | +| opencode | OpenCode | Anomaly (ex-SST) | full | guide | true | +| droid | Factory Droid | Factory AI | partial | guide | false | +| copilot | GitHub Copilot CLI | GitHub/MS | full | custom | false | +| cursor-cli | Cursor CLI | Anysphere | partial | guide | true | +| smelt | Smelt | leonardcser (OSS) | full | custom | false | +| pi | Pi (pi-coding-agent) | M. Zechner (OSS) | full | custom | false | +| grok-build | Grok Build | xAI | full | custom | false | +| crush | Crush | OSS (Charm) | full | custom | false | +| qwen | Qwen Code | Alibaba | full | guide | true | +| cursor | Cursor | Anysphere | none | guide | false | +| antigravity | Antigravity | Google | none | mitm | false | +| hermes | Hermes | Nous Research | none | guide | false | +| kiro | Kiro AI | Amazon | none | mitm | false | +| custom | Custom CLI | — | full | custom-builder | false | Tools with `baseUrlSupport: "partial"` show a badge "⚠ Base URL parcial" in the dashboard card. --- @@ -201,16 +215,16 @@ interface ToolBatchStatus { New tools with `configType: "custom"` have dedicated settings API routes: -| Route | Tool | -| ------------------------------------------- | ------------------------------ | -| `POST /api/cli-tools/forge-settings` | ForgeCode (.forge.toml) | -| `POST /api/cli-tools/jcode-settings` | jcode (--base-url flag) | -| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI (OPENAI_BASE_URL, legacy) | +| Route | Tool | +| ------------------------------------------- | ---------------------------------------------------------------- | +| `POST /api/cli-tools/forge-settings` | ForgeCode (.forge.toml) | +| `POST /api/cli-tools/jcode-settings` | jcode (--base-url flag) | +| `POST /api/cli-tools/deepseek-tui-settings` | DeepSeek TUI (OPENAI_BASE_URL, legacy) | | `POST /api/cli-tools/codewhale-settings` | CodeWhale (OPENAI_BASE_URL, primary + legacy `~/.deepseek` sync) | -| `POST /api/cli-tools/smelt-settings` | Smelt | -| `POST /api/cli-tools/pi-settings` | Pi coding agent | -| `POST /api/cli-tools/grok-build-settings` | Grok Build (~/.grok/config.toml, `[model.omniroute]`) | -| `POST /api/cli-tools/qwen-settings` | Qwen Code (`~/.qwen/settings.json` + dedicated `.env` key) | +| `POST /api/cli-tools/smelt-settings` | Smelt | +| `POST /api/cli-tools/pi-settings` | Pi coding agent | +| `POST /api/cli-tools/grok-build-settings` | Grok Build (~/.grok/config.toml, `[model.omniroute]`) | +| `POST /api/cli-tools/qwen-settings` | Qwen Code (`~/.qwen/settings.json` + dedicated `.env` key) | All routes use `sanitizeErrorMessage()` for error responses (Hard Rule #12). diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 8fc5a9666a..1592a760fb 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -376,7 +376,7 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, | ------------------------- | ----------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `CLI_MODE` | `auto` | `src/shared/services/cliRuntime.ts` | `auto` = search system PATH; `manual` = use explicit paths only. | | `CLI_EXTRA_PATHS` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Additional PATH entries for CLI binary discovery (colon-separated). | -| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). | +| `CLI_CONFIG_HOME` | _(unset)_ | `src/shared/services/cliRuntime.ts` | Override home directory for reading CLI configs (`~/.claude`, `~/.codex`). Must be absolute and inside the process home — **or**, in a container, a bind-mounted path (that is how `/host-home` works). Anything else falls back to the home dir. | | `CLI_ALLOW_CONFIG_WRITES` | `false` | `src/shared/services/cliRuntime.ts` | Allow OmniRoute to write CLI config files (token refresh, session data). | | `CLI_CLAUDE_BIN` | `claude` | `src/shared/services/cliRuntime.ts` | Custom path to Claude CLI binary. | | `CLI_CODEX_BIN` | `codex` | `src/shared/services/cliRuntime.ts` | Custom path to Codex CLI binary. | @@ -428,11 +428,25 @@ the CLI Code dashboard. ```bash # Mount host binaries into the container and tell OmniRoute where they are: CLI_EXTRA_PATHS=/host-cli/bin -CLI_CONFIG_HOME=/root +CLI_CONFIG_HOME=/host-home CLI_ALLOW_CONFIG_WRITES=true CLI_CLAUDE_BIN=/host-cli/bin/claude ``` +`CLI_CONFIG_HOME` only takes effect when the path is actually bind-mounted from +the host — pair it with mounts like `~/.codex:/host-home/.codex:rw` (see the +`host` profile in `docker-compose.yml`). A path that is neither inside the +container user's home nor a bind mount is ignored, because writing there would +be discarded when the container is recreated. + +The image runs as `USER node`, so an unmounted `/root` is **not** a valid +override. + +| Variable | Default | Source File | Description | +| ---------------------------------------- | ------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_CONTAINER` | _(auto)_ | `src/shared/utils/containerEnv.ts` | Force container detection on (`1`/`true`) or off (`0`/`false`). Only needed on runtimes the auto-detection misses. | +| `OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE` | `false` | `src/shared/services/cliRuntime.ts` | Allow CLI-tool config writes into an unmounted container path anyway. The CLI equivalent is `--allow-container-write`. | + ### CLI Binary (`omniroute`) helpers These variables tune the `omniroute` CLI binary's own behavior (not the sidecar diff --git a/skills/cli-setup/SKILL.md b/skills/cli-setup/SKILL.md index 4df7cb49bb..11697d2367 100644 --- a/skills/cli-setup/SKILL.md +++ b/skills/cli-setup/SKILL.md @@ -104,6 +104,7 @@ Write config for a tool - `--model ` - `--non-interactive` - `--yes` +- `--allow-container-write` **Example:** @@ -135,6 +136,7 @@ Generate OpenCode config (alias for - `--model ` - `--non-interactive` - `--yes` +- `--allow-container-write` **Example:** diff --git a/skills/cli-skill-collector/SKILL.md b/skills/cli-skill-collector/SKILL.md index add237ad11..ec3e36c37b 100644 --- a/skills/cli-skill-collector/SKILL.md +++ b/skills/cli-skill-collector/SKILL.md @@ -104,6 +104,7 @@ Write config for a tool - `--model ` - `--non-interactive` - `--yes` +- `--allow-container-write` **Example:** @@ -135,6 +136,7 @@ Generate OpenCode config (alias for - `--model ` - `--non-interactive` - `--yes` +- `--allow-container-write` **Example:** diff --git a/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx b/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx index 4f9452abdb..b7877dc05e 100644 --- a/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-code/components/DefaultToolCard.tsx @@ -303,6 +303,8 @@ export default function DefaultToolCard({ text: (typeof data.error === "string" ? data.error : data.error?.message) || t("failedToSave"), + // 422 from the container guard: the body is host-CLI guidance, not a failure. + containerEphemeralTarget: Boolean(data.containerEphemeralTarget), }); } } catch (error) { @@ -588,12 +590,16 @@ export default function DefaultToolCard({
{message && (
{message.type === "success" ? "check_circle" : "error"} - {message.text} + {/* The container refusal is a multi-line runbook — keep its line + breaks instead of collapsing it into one unreadable line. */} + + {message.text} +
)}
diff --git a/src/app/api/cli-tools/apply/route.ts b/src/app/api/cli-tools/apply/route.ts index b0235df1ee..dc7449bae4 100644 --- a/src/app/api/cli-tools/apply/route.ts +++ b/src/app/api/cli-tools/apply/route.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; import { generateConfig } from "@/lib/cli-helper/config-generator"; +import { guardCliConfigWrite } from "@/lib/api/cliConfigWriteGuard"; const applySchema = z.object({ toolId: z.string().min(1), @@ -22,6 +23,16 @@ const TOOL_CONFIG_PATHS: Record = { continue: path.join(os.homedir(), ".continue", "config.yaml"), }; +/** The host-side command that does the same job when OmniRoute is containerised. */ +const HOST_SETUP_COMMANDS: Record = { + claude: "omniroute setup-claude", + codex: "omniroute setup-codex", + opencode: "omniroute setup-opencode", + cline: "omniroute setup-cline", + kilocode: "omniroute setup-kilo", + continue: "omniroute setup-continue", +}; + function ensureBackup(configPath: string): string | null { if (!fs.existsSync(configPath)) return null; const backupDir = path.join(path.dirname(configPath), ".omniroute.bak"); @@ -69,6 +80,14 @@ export async function POST(request: Request) { return NextResponse.json({ error: `Unknown tool: ${toolId}` }, { status: 400 }); } + // A container write into an unmounted path looks successful and then + // disappears with the container — refuse it and point at the host CLI. + const refusal = guardCliConfigWrite(configPath, { + toolLabel: toolId, + hostCommand: HOST_SETUP_COMMANDS[toolId], + }); + if (refusal) return refusal; + const backupPath = ensureBackup(configPath); const dir = path.dirname(configPath); diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts index b0487d304b..f43895fcf5 100644 --- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts +++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts @@ -11,6 +11,27 @@ import { guideSettingsSaveSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { resolveApiKey, getOrCreateApiKey } from "@/shared/services/apiKeyResolver"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { guardCliConfigWrite } from "@/lib/api/cliConfigWriteGuard"; + +/** + * Where each guide tool's config lands, and the host command that writes the + * same thing when OmniRoute itself runs in a container. + */ +const GUIDE_TOOL_TARGETS: Record string; hostCommand: string }> = { + continue: { + resolve: () => path.join(os.homedir(), ".continue", "config.json"), + hostCommand: "omniroute setup-continue", + }, + opencode: { + resolve: () => getOpenCodeConfigPath(), + hostCommand: "omniroute setup-opencode", + }, + hermes: { + resolve: () => + getCliPrimaryConfigPath("hermes") || path.join(os.homedir(), ".hermes", "config.yaml"), + hostCommand: "omniroute config set hermes", + }, +}; /** * POST /api/cli-tools/guide-settings/:toolId @@ -58,6 +79,15 @@ export async function POST(request, { params }) { ? await resolveApiKey(apiKeyId, validation.data.apiKey) : await getOrCreateApiKey(); + const target = GUIDE_TOOL_TARGETS[toolId]; + if (target) { + const refusal = guardCliConfigWrite(target.resolve(), { + toolLabel: toolId, + hostCommand: target.hostCommand, + }); + if (refusal) return refusal; + } + try { switch (toolId) { case "continue": diff --git a/src/lib/api/cliConfigWriteGuard.ts b/src/lib/api/cliConfigWriteGuard.ts new file mode 100644 index 0000000000..0ef2e4cf97 --- /dev/null +++ b/src/lib/api/cliConfigWriteGuard.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { ensureCliConfigWriteAllowed } from "@/shared/services/cliRuntime"; +import { isContainerWriteRefusal } from "@/shared/utils/containerConfigGuard"; + +/** + * Shared gate for API routes that write a host CLI's config file. + * + * Returns `null` when the write may proceed, otherwise the response to send: + * - 422 + `containerEphemeralTarget` when OmniRoute runs in a container and + * the target is not bind-mounted from the host (the write would vanish), + * - 403 when CLI config writes are switched off entirely. + * + * Clients key off `containerEphemeralTarget` to render the host-CLI guidance + * inline, the same way the Zed import card handles its Docker 422. + */ +export function guardCliConfigWrite( + targetPath: string, + options: { toolLabel?: string; hostCommand?: string } = {} +): NextResponse | null { + const writeError = ensureCliConfigWriteAllowed(targetPath, options); + if (!writeError) return null; + + const containerEphemeralTarget = isContainerWriteRefusal(writeError); + return NextResponse.json( + { + error: writeError, + ...(containerEphemeralTarget + ? { containerEphemeralTarget, hostSetupCommand: options.hostCommand } + : {}), + }, + { status: containerEphemeralTarget ? 422 : 403 } + ); +} diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index a41cab7d8b..284618971c 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -9,6 +9,13 @@ import { withSettingsFallback } from "./cliInstallFallback"; import { GROK_BUILD_RUNTIME_ENTRY, AMP_RUNTIME_ENTRY } from "./cliRuntimeGrokBuild"; import { isLocationTrusted, findKnownPathMatch } from "./cliRuntimeKnownPath"; import { buildHealthcheckPath } from "./cliRuntimeHealthcheckPath"; +import { + describeContainerTarget, + hasBindMountAt, + isRunningInContainer, + type ContainerEnvDeps, +} from "../utils/containerEnv"; +import { buildContainerWriteRefusal } from "../utils/containerConfigGuard"; import { resolveOpencodeConfigPath as resolveOpenCodeConfigPath } from "./opencodeConfigPath"; const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]); const FALSE_VALUES = new Set(["0", "false", "no", "off"]); @@ -953,12 +960,32 @@ const checkRunnable = async ( export const isCliConfigWriteAllowed = () => parseBoolean(process.env.CLI_ALLOW_CONFIG_WRITES, true); -export const ensureCliConfigWriteAllowed = () => { - if (isCliConfigWriteAllowed()) return null; - return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)"; +/** + * Gate for every CLI-tool config write. + * + * Pass `targetPath` whenever the caller knows it: inside a container, a path + * that is not bind-mounted from the host is thrown away when the container is + * recreated, and the host CLI never sees it. Refusing beats writing a file the + * operator will never find. Callers that omit the path keep the historical + * flag-only behavior. + */ +export const ensureCliConfigWriteAllowed = ( + targetPath?: string, + options: { containerDeps?: ContainerEnvDeps; toolLabel?: string; hostCommand?: string } = {} +) => { + if (!isCliConfigWriteAllowed()) { + return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)"; + } + if (!targetPath) return null; + if (parseBoolean(process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE, false)) return null; + if (!describeContainerTarget(targetPath, options.containerDeps).ephemeral) return null; + return buildContainerWriteRefusal(targetPath, { + toolLabel: options.toolLabel, + hostCommand: options.hostCommand, + }); }; -export const getCliConfigHome = () => { +export const getCliConfigHome = (containerDeps?: ContainerEnvDeps) => { const override = String(process.env.CLI_CONFIG_HOME || "").trim(); if (!override) return os.homedir(); @@ -971,10 +998,18 @@ export const getCliConfigHome = () => { // Must not contain path traversal if (path.normalize(override).includes("..")) return os.homedir(); - // Must be within user's home directory (prevent reading from system dirs) + // Must be within user's home directory (prevent reading from system dirs). + // + // Exception for containers: the compose `host` profile deliberately mounts the + // operator's real config dirs at /host-home, which is outside the container + // user's home (/home/node). A bind mount is proof the operator wired that path + // in on purpose, so it is honoured; an arbitrary unmounted system dir is not. const home = os.homedir(); const normalized = path.normalize(override); if (!isPathWithin(normalized, home)) { + if (isRunningInContainer(containerDeps) && hasBindMountAt(normalized, containerDeps)) { + return normalized; + } return home; // Silently fall back to home } diff --git a/src/shared/utils/containerConfigGuard.ts b/src/shared/utils/containerConfigGuard.ts new file mode 100644 index 0000000000..2f4f8ca63f --- /dev/null +++ b/src/shared/utils/containerConfigGuard.ts @@ -0,0 +1,60 @@ +/** + * Shared wording for "this config write would vanish with the container". + * + * The CLI and the dashboard/API both refuse the same situation, so they share + * one message: an operator who hits it in the terminal and then again in the UI + * should read the same two escape routes. + */ + +export interface ContainerWriteRefusalOptions { + /** Human label for the tool being configured, e.g. "Codex". */ + toolLabel?: string; + /** The command that would fix it from the host, e.g. "omniroute setup-codex". */ + hostCommand?: string; + /** How to override, worded for the surface that is refusing. */ + overrideHint?: string; +} + +/** + * Opening words of every container refusal. Callers that receive a message + * rather than a structured result use `isContainerWriteRefusal()` to tell this + * apart from the other reasons a write can be denied. + */ +const REFUSAL_PREFIX = "Refusing to write"; + +export function isContainerWriteRefusal(message: string | null | undefined): boolean { + return typeof message === "string" && message.startsWith(REFUSAL_PREFIX); +} + +/** Default override hint for server-side (API) callers. */ +export const SERVER_OVERRIDE_HINT = + "Set OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE=true to configure the container's own CLIs anyway."; + +/** Default override hint for CLI callers. */ +export const CLI_OVERRIDE_HINT = + "Re-run with --allow-container-write to configure the container's own CLIs anyway."; + +export function buildContainerWriteRefusal( + targetPath: string, + options: ContainerWriteRefusalOptions = {} +): string { + const { toolLabel, hostCommand, overrideHint = SERVER_OVERRIDE_HINT } = options; + const subject = toolLabel ? `${toolLabel} config` : "CLI tool config"; + + return [ + `${REFUSAL_PREFIX} ${subject} to ${targetPath} — OmniRoute is running in a container ` + + `and that path is not mounted from the host, so the file would be discarded when the ` + + `container is recreated and your host CLI would never read it.`, + "", + "Configure from the host instead (recommended):", + " npm install -g omniroute", + " omniroute connect http://localhost:20128", + ` ${hostCommand || "omniroute setup-"}`, + "", + 'Or bind-mount the host config dir into the container (compose profile "host"):', + ' volumes: [ "~/.codex:/host-home/.codex:rw" ]', + ' environment: [ "CLI_CONFIG_HOME=/host-home", "CLI_ALLOW_CONFIG_WRITES=true" ]', + "", + overrideHint, + ].join("\n"); +} diff --git a/src/shared/utils/containerEnv.ts b/src/shared/utils/containerEnv.ts new file mode 100644 index 0000000000..00b81a5313 --- /dev/null +++ b/src/shared/utils/containerEnv.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; + +/** + * Container awareness for CLI-tool config writes. + * + * OmniRoute frequently runs as a container while the AI CLIs it configures + * (Codex, Claude Code, Cursor, ...) live on the operator's host. Writing + * `~/.codex/...` inside the container "succeeds" and then silently disappears + * with the container, so every auto-config write path consults this module + * before touching disk. + * + * A bind mount is treated as the operator's explicit statement that a path + * reaches the host, which is what makes the compose `host` profile safe. + */ + +export interface ContainerEnvDeps { + existsSync: (path: string) => boolean; + readFileSync: (path: string, encoding: string) => string; + env: NodeJS.ProcessEnv; +} + +const defaultDeps = (): ContainerEnvDeps => ({ + existsSync: fs.existsSync, + readFileSync: (path, encoding) => fs.readFileSync(path, encoding as BufferEncoding) as string, + env: process.env, +}); + +/** cgroup substrings emitted by the common container runtimes. */ +const CGROUP_MARKERS = ["docker", "containerd", "kubepods", "podman", "lxc"]; + +const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); +const FALSE_VALUES = new Set(["0", "false", "no", "off"]); + +/** + * Best-effort container detection. Superset of the Zed-specific + * `isRunningInDocker()` (kept separate so its callers keep their behavior). + * + * `OMNIROUTE_CONTAINER` forces the answer either way — needed for tests and for + * operators on exotic runtimes we fail to recognise. + */ +export function isRunningInContainer(deps: ContainerEnvDeps = defaultDeps()): boolean { + const override = String(deps.env?.OMNIROUTE_CONTAINER ?? "") + .trim() + .toLowerCase(); + if (TRUE_VALUES.has(override)) return true; + if (FALSE_VALUES.has(override)) return false; + + for (const marker of ["/.dockerenv", "/run/.containerenv"]) { + try { + if (deps.existsSync(marker)) return true; + } catch { + // not Linux, or permission denied — fall through to the next probe + } + } + + if (deps.env?.KUBERNETES_SERVICE_HOST) return true; + + try { + const cgroup = deps.readFileSync("/proc/1/cgroup", "utf8"); + if (CGROUP_MARKERS.some((marker) => cgroup.includes(marker))) return true; + } catch { + // /proc not mounted + } + + return false; +} + +/** mountinfo escapes these four characters as octal sequences. */ +function decodeMountPath(raw: string): string { + return raw + .replace(/\\040/g, " ") + .replace(/\\011/g, "\t") + .replace(/\\012/g, "\n") + .replace(/\\134/g, "\\"); +} + +/** Strip a trailing slash so "/host-home/" and "/host-home" compare equal. */ +function stripTrailingSlash(p: string): string { + return p.length > 1 && p.endsWith("/") ? p.replace(/\/+$/, "") : p; +} + +/** + * True when `targetPath` is connected to a mount, in any of three ways: + * + * 1. the path IS a mount point (`-v ~/.codex:/host-home/.codex`) + * 2. the path sits INSIDE a mount point (`/host-home/.codex/profiles`) + * 3. a mount point sits BENEATH the path (`/host-home`, whose children are + * the actual mounts — this is exactly how the compose `host` profile is + * wired, so case 3 is not optional) + * + * Returns false whenever `/proc/self/mountinfo` is unavailable, which keeps + * host machines (macOS, Windows) on the conservative path. + */ +export function hasBindMountAt( + targetPath: string, + deps: ContainerEnvDeps = defaultDeps() +): boolean { + const target = stripTrailingSlash(String(targetPath || "").trim()); + if (!target || !target.startsWith("/") || target === "/") return false; + + let content: string; + try { + content = deps.readFileSync("/proc/self/mountinfo", "utf8"); + } catch { + return false; + } + + for (const line of content.split("\n")) { + // mountinfo field 5 (1-indexed) is the mount point. + const fields = line.split(" "); + if (fields.length < 5) continue; + const mountPoint = stripTrailingSlash(decodeMountPath(fields[4] || "")); + if (!mountPoint || mountPoint === "/") continue; + + if (mountPoint === target) return true; + if (mountPoint.startsWith(`${target}/`)) return true; + if (target.startsWith(`${mountPoint}/`)) return true; + } + + return false; +} + +export interface ContainerTargetInfo { + inContainer: boolean; + bindMounted: boolean; + /** Writing here would be lost when the container is recreated. */ + ephemeral: boolean; +} + +/** + * Classify a would-be config write target. `ephemeral` is the signal callers + * act on: refuse the write and point the operator at the host CLI instead. + */ +export function describeContainerTarget( + targetPath: string, + deps: ContainerEnvDeps = defaultDeps() +): ContainerTargetInfo { + const inContainer = isRunningInContainer(deps); + if (!inContainer) { + return { inContainer: false, bindMounted: false, ephemeral: false }; + } + const bindMounted = hasBindMountAt(targetPath, deps); + return { inContainer: true, bindMounted, ephemeral: !bindMounted }; +} diff --git a/tests/unit/cli-config-home-container.test.ts b/tests/unit/cli-config-home-container.test.ts new file mode 100644 index 0000000000..1d85121fff --- /dev/null +++ b/tests/unit/cli-config-home-container.test.ts @@ -0,0 +1,149 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +// The compose `host` profile mounts the operator's real config dirs at +// /host-home, which sits OUTSIDE the container user's home (/home/node). Before +// this fix getCliConfigHome() silently dropped that override and every write +// landed back in the ephemeral container home. See docker-compose.yml. + +const modulePath = path.join(process.cwd(), "src/shared/services/cliRuntime.ts"); +const originalEnv = { ...process.env }; + +async function importFresh(label: string) { + return import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}-${Math.random()}`); +} + +function restoreEnv() { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); +} + +test.afterEach(restoreEnv); + +// Deps are injected because CI and dev machines are not containers and macOS +// has no /proc/self/mountinfo at all. +const HOST_PROFILE_MOUNTINFO = [ + "31 28 254:1 /volumes/omniroute-data/_data /app/data rw,relatime - ext4 /dev/vda1 rw", + "44 28 254:1 /Users/me/.codex /host-home/.codex rw,relatime - ext4 /dev/vda1 rw", + "45 28 254:1 /Users/me/.claude /host-home/.claude rw,relatime - ext4 /dev/vda1 rw", +].join("\n"); + +const containerDeps = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return HOST_PROFILE_MOUNTINFO; + if (p === "/proc/1/cgroup") return "12:cpuset:/docker/abc\n"; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, +}; + +const hostDeps = { + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => "12:cpuset:/\n", + env: {} as NodeJS.ProcessEnv, +}; + +test("container + bind-mounted CLI_CONFIG_HOME outside home is honoured", async () => { + const cliRuntime = await importFresh("container-mounted"); + process.env.CLI_CONFIG_HOME = "/host-home"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), "/host-home"); +}); + +test("container + unmounted CLI_CONFIG_HOME outside home still falls back", async () => { + const cliRuntime = await importFresh("container-unmounted"); + process.env.CLI_CONFIG_HOME = "/opt/not-mounted"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), os.homedir()); +}); + +test("host machine keeps rejecting an outside-home CLI_CONFIG_HOME", async () => { + const cliRuntime = await importFresh("host-outside"); + process.env.CLI_CONFIG_HOME = "/tmp/outside-home"; + assert.equal(cliRuntime.getCliConfigHome(hostDeps), os.homedir()); + // ...and with the real (non-container) environment too. + assert.equal(cliRuntime.getCliConfigHome(), os.homedir()); +}); + +test("container exception does not bypass the other CLI_CONFIG_HOME guards", async () => { + const cliRuntime = await importFresh("container-guards"); + const home = os.homedir(); + + process.env.CLI_CONFIG_HOME = "relative/host-home"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), home, "relative paths rejected"); + + process.env.CLI_CONFIG_HOME = "/host-home/../etc"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), home, "traversal rejected"); + + process.env.CLI_CONFIG_HOME = "/host-home;rm -rf /"; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), home, "metacharacters rejected"); +}); + +test("an in-home CLI_CONFIG_HOME is unaffected by container detection", async () => { + const cliRuntime = await importFresh("in-home"); + const safe = path.join(os.homedir(), "tmp-cli-config-home"); + process.env.CLI_CONFIG_HOME = safe; + assert.equal(cliRuntime.getCliConfigHome(containerDeps), safe); + assert.equal(cliRuntime.getCliConfigHome(hostDeps), safe); +}); + +// ── ensureCliConfigWriteAllowed ────────────────────────────────────────────── + +test("ensureCliConfigWriteAllowed without a path keeps flag-only behavior", async () => { + const cliRuntime = await importFresh("gate-flag-only"); + assert.equal(cliRuntime.ensureCliConfigWriteAllowed(), null); + + process.env.CLI_ALLOW_CONFIG_WRITES = "false"; + assert.match(cliRuntime.ensureCliConfigWriteAllowed(), /CLI_ALLOW_CONFIG_WRITES=false/); +}); + +test("ensureCliConfigWriteAllowed refuses an ephemeral container target", async () => { + const cliRuntime = await importFresh("gate-ephemeral"); + const message = cliRuntime.ensureCliConfigWriteAllowed("/home/node/.codex", { containerDeps }); + assert.ok(message, "expected a refusal"); + assert.match(message, /Refusing to write/); + assert.match(message, /\/home\/node\/\.codex/); + assert.match(message, /omniroute connect/); + assert.match(message, /CLI_CONFIG_HOME=\/host-home/); +}); + +test("ensureCliConfigWriteAllowed allows a bind-mounted container target", async () => { + const cliRuntime = await importFresh("gate-mounted"); + assert.equal( + cliRuntime.ensureCliConfigWriteAllowed("/host-home/.codex/config.toml", { containerDeps }), + null + ); +}); + +test("ensureCliConfigWriteAllowed allows any target on a host", async () => { + const cliRuntime = await importFresh("gate-host"); + assert.equal( + cliRuntime.ensureCliConfigWriteAllowed(path.join(os.homedir(), ".codex"), { + containerDeps: hostDeps, + }), + null + ); +}); + +test("OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE overrides the container refusal", async () => { + const cliRuntime = await importFresh("gate-override"); + process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "true"; + assert.equal( + cliRuntime.ensureCliConfigWriteAllowed("/home/node/.codex", { containerDeps }), + null + ); +}); + +test("the write-disabled flag still wins over the container override", async () => { + const cliRuntime = await importFresh("gate-precedence"); + process.env.CLI_ALLOW_CONFIG_WRITES = "false"; + process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "true"; + assert.match( + cliRuntime.ensureCliConfigWriteAllowed("/home/node/.codex", { containerDeps }), + /CLI_ALLOW_CONFIG_WRITES=false/ + ); +}); diff --git a/tests/unit/cli-container-write-guard.test.ts b/tests/unit/cli-container-write-guard.test.ts new file mode 100644 index 0000000000..0a2cfaccdb --- /dev/null +++ b/tests/unit/cli-container-write-guard.test.ts @@ -0,0 +1,125 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + assertHostConfigTarget, + guardHostConfigTarget, + CONTAINER_WRITE_EXIT_CODE, +} from "../../bin/cli/utils/config-home-guard.mjs"; + +// Container/mount state is injected — CI and dev machines are not containers, +// and macOS has no /proc/self/mountinfo. + +const MOUNTINFO = "44 28 254:1 /Users/me/.codex /host-home/.codex rw,relatime - ext4 /dev/vda1 rw"; + +const containerDeps = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return MOUNTINFO; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, +}; + +const hostDeps = { + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => "12:cpuset:/\n", + env: {} as NodeJS.ProcessEnv, +}; + +test("guard allows any write on a host machine", async () => { + const result = await assertHostConfigTarget("/Users/me/.codex", { + deps: hostDeps, + env: {}, + }); + assert.deepEqual(result, { ok: true }); +}); + +test("guard refuses an ephemeral container home and explains both escape routes", async () => { + const result = await assertHostConfigTarget("/home/node/.codex", { + toolLabel: "Codex", + hostCommand: "omniroute setup-codex", + deps: containerDeps, + env: {}, + }); + + assert.equal(result.ok, false); + assert.match(result.message!, /Refusing to write Codex config to \/home\/node\/\.codex/); + assert.match(result.message!, /omniroute setup-codex/); + assert.match(result.message!, /CLI_CONFIG_HOME=\/host-home/); + assert.match(result.message!, /--allow-container-write/); +}); + +test("guard allows a bind-mounted container target without warning", async () => { + const result = await assertHostConfigTarget("/host-home/.codex/glm.config.toml", { + deps: containerDeps, + env: {}, + }); + assert.deepEqual(result, { ok: true }); +}); + +test("--allow-container-write proceeds but warns about the ephemeral write", async () => { + const result = await assertHostConfigTarget("/home/node/.codex", { + allowContainerWrite: true, + deps: containerDeps, + env: {}, + }); + assert.equal(result.ok, true); + assert.match(result.warning!, /lost when the container is recreated/); +}); + +test("OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE env has the same effect as the flag", async () => { + for (const value of ["1", "true", "yes", "on", "TRUE"]) { + const result = await assertHostConfigTarget("/home/node/.codex", { + deps: containerDeps, + env: { OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE: value }, + }); + assert.equal(result.ok, true, `expected ${value} to allow the write`); + } +}); + +test("a falsy env override does not allow the write", async () => { + for (const value of ["0", "false", "off", ""]) { + const result = await assertHostConfigTarget("/home/node/.codex", { + deps: containerDeps, + env: { OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE: value }, + }); + assert.equal(result.ok, false, `expected ${value} to keep the refusal`); + } +}); + +test("--dry-run is not blocked but says a real run would be refused", async () => { + const result = await assertHostConfigTarget("/home/node/.codex", { + dryRun: true, + deps: containerDeps, + env: {}, + }); + assert.equal(result.ok, true); + assert.match(result.warning!, /\[dry-run\]/); + assert.match(result.warning!, /would be refused/); +}); + +test("guardHostConfigTarget returns exit code 2 on refusal and 0 otherwise", async () => { + const originalLog = console.log; + const lines: string[] = []; + console.log = (msg?: unknown) => { + lines.push(String(msg)); + }; + try { + const blocked = await guardHostConfigTarget("/home/node/.codex", { + deps: containerDeps, + env: {}, + }); + const allowed = await guardHostConfigTarget("/host-home/.codex", { + deps: containerDeps, + env: {}, + }); + assert.equal(blocked, CONTAINER_WRITE_EXIT_CODE); + assert.equal(allowed, 0); + assert.ok( + lines.some((l) => l.includes("Refusing to write")), + "refusal should be printed" + ); + } finally { + console.log = originalLog; + } +}); diff --git a/tests/unit/cli-setup-container-guard-coverage.test.ts b/tests/unit/cli-setup-container-guard-coverage.test.ts new file mode 100644 index 0000000000..536fa9e986 --- /dev/null +++ b/tests/unit/cli-setup-container-guard-coverage.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +/** + * Static scan, not behavior: a new `setup-*` command that writes a CLI-tool + * config must not silently no-op inside the OmniRoute container. Anything that + * writes has to route through the container guard first. + */ + +const COMMANDS_DIR = path.join(process.cwd(), "bin/cli/commands"); +const WRITE_CALLS = /\b(writeFileSync|writeAtomic|cpSync|copyFileSync|renameSync)\s*\(/; +const GUARD_CALL = /guardHostConfigTarget\s*\(/; + +/** + * Commands whose writes never target a host CLI's own config (they write to a + * user-chosen --out path, OmniRoute's own data dir, etc.). Keep this list tiny + * and justified — an entry here is an opt-out from the guard. + */ +const NOT_CLI_TOOL_CONFIG = new Set([]); + +function setupCommandFiles(): string[] { + return fs + .readdirSync(COMMANDS_DIR) + .filter((name) => name.startsWith("setup-") && name.endsWith(".mjs")) + .sort(); +} + +test("every setup-* command that writes files calls the container guard", () => { + const offenders: string[] = []; + + for (const name of setupCommandFiles()) { + if (NOT_CLI_TOOL_CONFIG.has(name)) continue; + const source = fs.readFileSync(path.join(COMMANDS_DIR, name), "utf8"); + if (!WRITE_CALLS.test(source)) continue; + if (!GUARD_CALL.test(source)) offenders.push(name); + } + + assert.deepEqual( + offenders, + [], + `these setup-* commands write config without guardHostConfigTarget(): ${offenders.join(", ")}` + ); +}); + +test("every guarded setup-* command exposes --allow-container-write", () => { + const offenders: string[] = []; + + for (const name of setupCommandFiles()) { + const source = fs.readFileSync(path.join(COMMANDS_DIR, name), "utf8"); + if (!GUARD_CALL.test(source)) continue; + if (!source.includes("--allow-container-write")) offenders.push(name); + } + + assert.deepEqual(offenders, [], `missing the --allow-container-write escape hatch: ${offenders}`); +}); + +test("the scan actually sees the commands it is meant to protect", () => { + const files = setupCommandFiles(); + assert.ok(files.length >= 12, `expected the setup-* family, found ${files.length}`); + for (const expected of ["setup-codex.mjs", "setup-claude.mjs", "setup-crush.mjs"]) { + assert.ok(files.includes(expected), `${expected} should be scanned`); + } +}); + +test("config set and configure are guarded too", () => { + for (const name of ["config.mjs", "configure.mjs"]) { + const source = fs.readFileSync(path.join(COMMANDS_DIR, name), "utf8"); + assert.match(source, GUARD_CALL, `${name} should call the container guard`); + assert.ok( + source.includes("--allow-container-write"), + `${name} should expose --allow-container-write` + ); + } +}); diff --git a/tests/unit/cli-tools-apply-container-422.test.ts b/tests/unit/cli-tools-apply-container-422.test.ts new file mode 100644 index 0000000000..b4fc5cf6bf --- /dev/null +++ b/tests/unit/cli-tools-apply-container-422.test.ts @@ -0,0 +1,164 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +/** + * POST /api/cli-tools/apply writes host CLI config files. Inside a container + * with no bind mount that write is thrown away with the container, so the route + * must refuse with a structured 422 instead of reporting success. + */ + +const routePath = path.join(process.cwd(), "src/app/api/cli-tools/apply/route.ts"); +const originalEnv = { ...process.env }; +const tempDirs = new Set(); + +async function importRoute(label: string) { + return import(`${pathToFileURL(routePath).href}?case=${label}-${Date.now()}-${Math.random()}`); +} + +function restoreEnv() { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + Object.assign(process.env, originalEnv); +} + +test.afterEach(restoreEnv); + +// The auth guard reads settings, which opens the SQLite singleton. Releasing it +// before the temp dirs go away keeps the node:test runner from hanging on an +// open handle (see AGENTS.md → "Database Handles in Tests"). +test.after(async () => { + try { + const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + resetDbInstance(); + } catch { + // the DB was never opened + } + for (const dir of tempDirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function applyRequest(body: Record) { + return new Request("http://localhost:20128/api/cli-tools/apply", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ toolId: "codex", apiKey: "sk-test", ...body }), + }); +} + +test("refuses with 422 and does not write when the target is container-ephemeral", async () => { + // OMNIROUTE_CONTAINER forces detection; the fake HOME has no bind mount, so + // the target classifies as ephemeral. + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-ephemeral-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE; + + const { POST } = await importRoute("ephemeral"); + const response = await POST(applyRequest({})); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.containerEphemeralTarget, true); + assert.equal(body.hostSetupCommand, "omniroute setup-codex"); + assert.match(body.error, /Refusing to write/); + assert.match(body.error, /omniroute connect/); + // Nothing may hit disk. + assert.equal(fs.existsSync(path.join(fakeHome, ".codex")), false); +}); + +test("the 422 body carries no stack trace", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-stack-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + const { POST } = await importRoute("nostack"); + const body = await (await POST(applyRequest({}))).json(); + + assert.ok(!body.error.includes("at /"), "error must not leak a stack trace"); + assert.ok(!body.error.includes(".ts:"), "error must not leak source locations"); +}); + +test("dry-run still previews the config inside a container", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-dry-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + const { POST } = await importRoute("dryrun"); + const response = await POST(applyRequest({ dryRun: true })); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.dryRun, true); +}); + +test("OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE lets the write through", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-override-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "true"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + const { POST } = await importRoute("override"); + const response = await POST(applyRequest({})); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.success, true); + assert.ok(fs.existsSync(body.configPath), `expected ${body.configPath} to be written`); +}); + +test("the dashboard's guide-settings writer refuses the same way", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-guide-ephemeral-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "1"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE; + + const guideRoute = path.join( + process.cwd(), + "src/app/api/cli-tools/guide-settings/[toolId]/route.ts" + ); + const { POST } = await import(`${pathToFileURL(guideRoute).href}?case=guide-${Date.now()}`); + + const response = await POST( + new Request("http://localhost:20128/api/cli-tools/guide-settings/continue", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ baseUrl: "http://localhost:20128/v1", model: "glm/glm-5.2" }), + }), + { params: Promise.resolve({ toolId: "continue" }) } + ); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.containerEphemeralTarget, true); + assert.equal(body.hostSetupCommand, "omniroute setup-continue"); + assert.equal(fs.existsSync(path.join(fakeHome, ".continue")), false); +}); + +test("a host environment applies the config normally", async () => { + const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "or-apply-host-")); + tempDirs.add(fakeHome); + process.env.OMNIROUTE_CONTAINER = "0"; + process.env.HOME = fakeHome; + process.env.USERPROFILE = fakeHome; + + const { POST } = await importRoute("host"); + const response = await POST(applyRequest({})); + + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.success, true); +}); diff --git a/tests/unit/container-env-detect.test.ts b/tests/unit/container-env-detect.test.ts new file mode 100644 index 0000000000..4e963fd514 --- /dev/null +++ b/tests/unit/container-env-detect.test.ts @@ -0,0 +1,235 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + describeContainerTarget, + hasBindMountAt, + isRunningInContainer, +} from "../../src/shared/utils/containerEnv.ts"; + +// Dependency injection everywhere — no module mocking, no real /proc reads. + +const throwingFs = { + existsSync: (_p: string) => { + throw new Error("ENOENT"); + }, + readFileSync: (_p: string, _enc: string): string => { + throw new Error("ENOENT"); + }, +}; + +const hostDeps = { + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => "12:cpuset:/\n", + env: {} as NodeJS.ProcessEnv, +}; + +// A realistic mountinfo from the compose `host` profile: /host-home itself is a +// plain directory created by Docker, only the per-tool dirs are bind mounts. +const HOST_PROFILE_MOUNTINFO = [ + "22 28 0:20 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw", + "24 28 0:22 / /sys ro,nosuid,nodev,noexec,relatime - sysfs sysfs ro", + "31 28 254:1 /var/lib/docker/volumes/omniroute-data/_data /app/data rw,relatime - ext4 /dev/vda1 rw", + "44 28 254:1 /Users/me/.codex /host-home/.codex rw,relatime - ext4 /dev/vda1 rw", + "45 28 254:1 /Users/me/.claude /host-home/.claude rw,relatime - ext4 /dev/vda1 rw", + "", +].join("\n"); + +// ── isRunningInContainer ───────────────────────────────────────────────────── + +test("isRunningInContainer detects /.dockerenv", () => { + assert.equal( + isRunningInContainer({ + ...throwingFs, + existsSync: (p: string) => p === "/.dockerenv", + env: {}, + }), + true + ); +}); + +test("isRunningInContainer detects Podman via /run/.containerenv", () => { + assert.equal( + isRunningInContainer({ + ...throwingFs, + existsSync: (p: string) => p === "/run/.containerenv", + env: {}, + }), + true + ); +}); + +test("isRunningInContainer detects Kubernetes via KUBERNETES_SERVICE_HOST", () => { + assert.equal( + isRunningInContainer({ + ...throwingFs, + existsSync: (_p: string) => false, + env: { KUBERNETES_SERVICE_HOST: "10.96.0.1" }, + }), + true + ); +}); + +for (const marker of ["docker", "containerd", "kubepods", "podman", "lxc"]) { + test(`isRunningInContainer detects '${marker}' in /proc/1/cgroup`, () => { + assert.equal( + isRunningInContainer({ + existsSync: (_p: string) => false, + readFileSync: (_p: string, _enc: string) => `12:cpuset:/${marker}/abc123\n`, + env: {}, + }), + true + ); + }); +} + +test("isRunningInContainer returns false on a plain host", () => { + assert.equal(isRunningInContainer(hostDeps), false); +}); + +test("isRunningInContainer returns false when every probe throws", () => { + assert.equal(isRunningInContainer({ ...throwingFs, env: {} }), false); +}); + +test("OMNIROUTE_CONTAINER=1 forces detection on even without container markers", () => { + assert.equal(isRunningInContainer({ ...hostDeps, env: { OMNIROUTE_CONTAINER: "1" } }), true); + assert.equal(isRunningInContainer({ ...hostDeps, env: { OMNIROUTE_CONTAINER: "true" } }), true); +}); + +test("OMNIROUTE_CONTAINER=0 forces detection off even inside a container", () => { + const inContainer = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (_p: string, _enc: string) => "12:cpuset:/docker/abc\n", + env: { OMNIROUTE_CONTAINER: "0" } as NodeJS.ProcessEnv, + }; + assert.equal(isRunningInContainer(inContainer), false); + assert.equal( + isRunningInContainer({ ...inContainer, env: { OMNIROUTE_CONTAINER: "false" } }), + false + ); +}); + +// ── hasBindMountAt ─────────────────────────────────────────────────────────── + +const mountDeps = (mountinfo: string) => ({ + existsSync: (_p: string) => true, + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return mountinfo; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, +}); + +test("hasBindMountAt is true for a directory whose children are bind mounts", () => { + // The compose `host` profile case: CLI_CONFIG_HOME=/host-home is not itself a + // mount point, but ~/.codex and ~/.claude are mounted beneath it. + assert.equal(hasBindMountAt("/host-home", mountDeps(HOST_PROFILE_MOUNTINFO)), true); +}); + +test("hasBindMountAt is true for an exact mount point", () => { + assert.equal(hasBindMountAt("/host-home/.codex", mountDeps(HOST_PROFILE_MOUNTINFO)), true); + assert.equal(hasBindMountAt("/app/data", mountDeps(HOST_PROFILE_MOUNTINFO)), true); +}); + +test("hasBindMountAt is true for a path nested inside a mount point", () => { + assert.equal( + hasBindMountAt("/host-home/.codex/profiles", mountDeps(HOST_PROFILE_MOUNTINFO)), + true + ); +}); + +test("hasBindMountAt ignores trailing slashes", () => { + assert.equal(hasBindMountAt("/host-home/", mountDeps(HOST_PROFILE_MOUNTINFO)), true); +}); + +test("hasBindMountAt is false for an unmounted container path", () => { + assert.equal(hasBindMountAt("/home/node", mountDeps(HOST_PROFILE_MOUNTINFO)), false); + assert.equal(hasBindMountAt("/opt/whatever", mountDeps(HOST_PROFILE_MOUNTINFO)), false); +}); + +test("hasBindMountAt never treats / as a bind mount", () => { + assert.equal(hasBindMountAt("/", mountDeps(HOST_PROFILE_MOUNTINFO)), false); +}); + +test("hasBindMountAt decodes octal escapes in mount points", () => { + const mountinfo = "44 28 254:1 / /host-home/my\\040dir rw,relatime - ext4 /dev/vda1 rw\n"; + assert.equal(hasBindMountAt("/host-home/my dir", mountDeps(mountinfo)), true); +}); + +test("hasBindMountAt returns false when /proc/self/mountinfo is unreadable", () => { + assert.equal(hasBindMountAt("/host-home", { ...throwingFs, env: {} }), false); +}); + +test("hasBindMountAt tolerates malformed mountinfo lines", () => { + const mountinfo = [ + "garbage", + "1 2 3", + "", + "44 28 254:1 / /host-home rw - ext4 /dev/vda1 rw", + ].join("\n"); + assert.equal(hasBindMountAt("/host-home", mountDeps(mountinfo)), true); + assert.equal(hasBindMountAt("/nope", mountDeps(mountinfo)), false); +}); + +test("hasBindMountAt returns false for empty or relative paths", () => { + assert.equal(hasBindMountAt("", mountDeps(HOST_PROFILE_MOUNTINFO)), false); + assert.equal(hasBindMountAt("relative/path", mountDeps(HOST_PROFILE_MOUNTINFO)), false); +}); + +// ── describeContainerTarget ────────────────────────────────────────────────── + +test("describeContainerTarget flags an ephemeral container home", () => { + const deps = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return HOST_PROFILE_MOUNTINFO; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, + }; + assert.deepEqual(describeContainerTarget("/home/node/.codex", deps), { + inContainer: true, + bindMounted: false, + ephemeral: true, + }); +}); + +test("describeContainerTarget clears ephemeral for a bind-mounted target", () => { + const deps = { + existsSync: (p: string) => p === "/.dockerenv", + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") return HOST_PROFILE_MOUNTINFO; + throw new Error("ENOENT"); + }, + env: {} as NodeJS.ProcessEnv, + }; + assert.deepEqual(describeContainerTarget("/host-home/.codex/foo.toml", deps), { + inContainer: true, + bindMounted: true, + ephemeral: false, + }); +}); + +test("describeContainerTarget is inert on a host", () => { + assert.deepEqual(describeContainerTarget("/Users/me/.codex", hostDeps), { + inContainer: false, + bindMounted: false, + ephemeral: false, + }); +}); + +test("describeContainerTarget does not probe mounts when not in a container", () => { + let mountReads = 0; + const deps = { + existsSync: (_p: string) => false, + readFileSync: (p: string, _enc: string) => { + if (p === "/proc/self/mountinfo") { + mountReads += 1; + return HOST_PROFILE_MOUNTINFO; + } + return "12:cpuset:/\n"; + }, + env: {} as NodeJS.ProcessEnv, + }; + describeContainerTarget("/Users/me/.codex", deps); + assert.equal(mountReads, 0); +}); From be6f18b8490411f2c7338027f04c844661669d05 Mon Sep 17 00:00:00 2001 From: Chewji <126886556+Chewji9875@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:42:20 +0700 Subject: [PATCH 28/36] fix(account-fallback): classify 'insufficient credits' as credits-exhausted (#10116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(account-fallback): classify 'insufficient credits' as credits-exhausted Command Code returns 400 'You have insufficient credits to make this request...' when an account's billing credits run out. The phrase was missing from CREDITS_EXHAUSTED_SIGNALS, so the error stayed unclassified (errorType=null) and the connection was never marked credits_exhausted — getProviderCredentials kept re-selecting the same dead account on every request instead of rotating to a healthy one. Add 'insufficient credits'/'insufficient credit' to the signal list (already used by antigravity429Engine.ts) so the error classifies as QUOTA_EXHAUSTED and the account is skipped on subsequent selections. * fix(account-fallback): harden insufficient-credit matching and preserve chatanywhere Add the common 'insufficient credit balance' variation to CREDITS_EXHAUSTED_SIGNALS alongside the Command Code 'insufficient credits'/'insufficient credit' signals, and restore the consolidated ChatAnywhere gateway entry that the stale snapshot removal would have deleted when merging into release/v3.8.50. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: adevwithpurpose --- open-sse/services/accountFallback.ts | 8 ++++++++ tests/unit/account-fallback-service.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 7fb8a341e4..2cf9105121 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -211,6 +211,14 @@ export const CREDITS_EXHAUSTED_SIGNALS = [ "insufficient balance", "insufficient_balance", "insufficient account balance", + "insufficient credit balance", + // Command Code returns 400 "You have insufficient credits to make this + // request. Please purchase more credits to continue using the service." + // when the account's billing credits run out. Without this signal the + // error stays unclassified (errorType=null), so the connection is never + // marked credits_exhausted and keeps being re-selected on every request. + "insufficient credits", + "insufficient credit", ]; // T11: Signals that indicate OAuth token is invalid/expired (not permanent deactivation) diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 26da5a7d5a..5bd6ca24a8 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -1144,6 +1144,19 @@ test("isCreditsExhausted returns true for actual credits-exhausted signals", () // #5239: "Insufficient account balance" out-of-credit bodies assert.equal(isCreditsExhausted("Insufficient account balance"), true); assert.equal(isCreditsExhausted("insufficient_balance"), true); + assert.equal(isCreditsExhausted("Insufficient credit balance"), true); + // Command Code returns 400 "You have insufficient credits to make this + // request. Please purchase more credits to continue using the service." + // when the account's billing credits run out. Without this signal the + // error is unclassified (errorType=null), so the connection is never + // marked credits_exhausted and keeps being re-selected on every request. + assert.equal(isCreditsExhausted("insufficient credits"), true); + assert.equal( + isCreditsExhausted( + "You have insufficient credits to make this request. Please purchase more credits to continue using the service." + ), + true + ); }); test("CREDITS_EXHAUSTED_SIGNALS no longer contains generic gRPC resource-exhausted patterns", () => { From d010a9979fe71b35e7c24d9922f155bfbdc1af86 Mon Sep 17 00:00:00 2001 From: Paco Cartones <253313177+pacocartones@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:42:25 +0200 Subject: [PATCH 29/36] fix(providers): repoint freeaiapikey to its live API host and resync its catalog (#10233) * fix(providers): point freeaiapikey at the api. host it moved to Every /v1 route on the freeaiapikey.com apex host answers HTTP 410 with type "endpoint_moved", and the body names its own replacement: "This API endpoint has moved. Please update your base_url to https://api.freeaiapikey.com/v1 - the old endpoint on freeaiapikey.com no longer works." Probed 2026-08-13 with paired controls so a network fault could not be read as an upstream verdict: GET https://freeaiapikey.com/v1/models -> 410 GET https://freeaiapikey.com/v1/chat/completions -> 410 GET https://api.freeaiapikey.com/v1/models -> 200 GET https://api.freeaiapikey.com/v1/chat/completions -> 405 (POST-only) GET https://api.openai.com/v1/models -> 401 (control: reachable) GET https:///v1/models -> 000 (control: unreachable) Every request through this provider therefore fails today. Repoint baseUrl and modelsUrl at the host upstream names. * fix(providers): resync the freeaiapikey catalog with its live model list GET https://api.freeaiapikey.com/v1/models (200, probed 2026-08-13) serves 10 models. The registry declared 7, four of which upstream does not serve at all: openai/gpt-5, openai/gpt-5.2-codex, Alibaba/qwen3.5, Alibaba/qwen3-vl:235b. Seven live models were missing: openai/gpt-5.4, openai/gpt-5.5, openai/gpt-5.6-sol, anthropic/claude-opus-4.7, anthropic/claude-opus-4.8, anthropic/claude-sonnet-5, anthropic/claude-opus-5. The four phantom ids are selectable in the dashboard and can only ever fail upstream; the seven real ones are unreachable through the static catalog. On context windows: the /v1/models response carries only id/object/created/ owned_by, so upstream publishes no window at all. The models added here therefore declare no contextLength and inherit the entry's existing defaultContextLength (128000) instead of a fabricated number. The two pre-existing contextLength values are left untouched for the same reason - this sweep neither confirms nor refutes them, and rewriting them would be guesswork in the other direction. * chore(changelog): name the fragment after the real PR number * chore(changelog): substitute the PRNUM placeholder in the fragment body --------- Co-authored-by: adevwithpurpose --- .../10233-freeaiapikey-endpoint-moved.md | 1 + .../providers/registry/freeaiapikey/index.ts | 33 +++--- tests/snapshots/provider/translate-path.json | 4 +- .../unit/freeaiapikey-endpoint-moved.test.ts | 104 ++++++++++++++++++ 4 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md create mode 100644 tests/unit/freeaiapikey-endpoint-moved.test.ts diff --git a/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md b/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md new file mode 100644 index 0000000000..cd7abc5c32 --- /dev/null +++ b/changelog.d/fixes/10233-freeaiapikey-endpoint-moved.md @@ -0,0 +1 @@ +- **fix(providers):** FreeAIAPIKey now targets `api.freeaiapikey.com`, the host upstream names in its `410 endpoint_moved` response — every request through the provider was failing — and its catalog is resynced to the 10 models the live `/v1/models` actually serves ([#10233](https://github.com/diegosouzapw/OmniRoute/pull/10233)) diff --git a/open-sse/config/providers/registry/freeaiapikey/index.ts b/open-sse/config/providers/registry/freeaiapikey/index.ts index 2fe8a4ba5d..6a4785990d 100644 --- a/open-sse/config/providers/registry/freeaiapikey/index.ts +++ b/open-sse/config/providers/registry/freeaiapikey/index.ts @@ -5,34 +5,39 @@ export const freeaiapikeyProvider: RegistryEntry = { alias: "faik", format: "openai", executor: "default", - baseUrl: "https://freeaiapikey.com/v1/chat/completions", - modelsUrl: "https://freeaiapikey.com/v1/models", + // 2026-08-13: the apex host answers 410 `endpoint_moved` on every /v1 route and + // names its own replacement — "Please update your base_url to + // https://api.freeaiapikey.com/v1". The api. host serves /v1/models (200) and + // /v1/chat/completions (405 on GET, i.e. POST-only as expected). + baseUrl: "https://api.freeaiapikey.com/v1/chat/completions", + modelsUrl: "https://api.freeaiapikey.com/v1/models", authType: "apikey", authHeader: "bearer", defaultContextLength: 128000, + // Catalog synced 2026-08-13 against GET https://api.freeaiapikey.com/v1/models (200). + // That response carries only id/object/created/owned_by — upstream publishes no + // context window — so models added from it declare no contextLength and inherit + // `defaultContextLength` above rather than an invented figure. The two pre-existing + // contextLength values are left exactly as they were: nothing in this sweep confirms + // or refutes them, and rewriting them would be the same guesswork in reverse. models: [ - { id: "openai/gpt-5", name: "GPT-5 (via FreeAIAPIKey)", contextLength: 400000 }, { id: "openai/gpt-4o", name: "GPT-4o (via FreeAIAPIKey)" }, - { id: "openai/gpt-5.2-codex", name: "GPT-5.2 Codex (via FreeAIAPIKey)" }, + { id: "openai/gpt-5.4", name: "GPT-5.4 (via FreeAIAPIKey)" }, + { id: "openai/gpt-5.5", name: "GPT-5.5 (via FreeAIAPIKey)" }, + { id: "openai/gpt-5.6-sol", name: "GPT-5.6 Sol (via FreeAIAPIKey)" }, { id: "anthropic/claude-opus-4.6", name: "Claude Opus 4.6 (via FreeAIAPIKey)", contextLength: 1000000, }, + { id: "anthropic/claude-opus-4.7", name: "Claude Opus 4.7 (via FreeAIAPIKey)" }, + { id: "anthropic/claude-opus-4.8", name: "Claude Opus 4.8 (via FreeAIAPIKey)" }, + { id: "anthropic/claude-opus-5", name: "Claude Opus 5 (via FreeAIAPIKey)" }, { id: "anthropic/claude-sonnet-4.6", name: "Claude Sonnet 4.6 (via FreeAIAPIKey)", contextLength: 1000000, }, - { - id: "Alibaba/qwen3.5", - name: "Qwen 3.5 (via FreeAIAPIKey)", - contextLength: 128000, - }, - { - id: "Alibaba/qwen3-vl:235b", - name: "Qwen 3 VL 235B (via FreeAIAPIKey)", - contextLength: 128000, - }, + { id: "anthropic/claude-sonnet-5", name: "Claude Sonnet 5 (via FreeAIAPIKey)" }, ], }; diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index b476020a95..9d6a45573b 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -2082,8 +2082,8 @@ } }, "url": { - "nonStream": "https://freeaiapikey.com/v1/chat/completions", - "stream": "https://freeaiapikey.com/v1/chat/completions" + "nonStream": "https://api.freeaiapikey.com/v1/chat/completions", + "stream": "https://api.freeaiapikey.com/v1/chat/completions" } }, "freeinference": { diff --git a/tests/unit/freeaiapikey-endpoint-moved.test.ts b/tests/unit/freeaiapikey-endpoint-moved.test.ts new file mode 100644 index 0000000000..6176bcf46c --- /dev/null +++ b/tests/unit/freeaiapikey-endpoint-moved.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { freeaiapikeyProvider } from "../../open-sse/config/providers/registry/freeaiapikey/index.ts"; + +/** + * FreeAIAPIKey retired its apex-host API and moved it to a dedicated `api.` host. + * + * Live probe (2026-08-13), each paired with a control call so a network fault + * cannot be mistaken for an upstream verdict: + * + * GET https://freeaiapikey.com/v1/models → 410 + * GET https://freeaiapikey.com/v1/chat/completions → 410 + * GET https://api.freeaiapikey.com/v1/models → 200 + * GET https://api.freeaiapikey.com/v1/chat/completions → 405 (POST-only endpoint) + * GET https://api.openai.com/v1/models → 401 (control: reachable) + * GET https:///v1/models → 000 (control: unreachable) + * + * The 410 body names its own replacement, so the target host is upstream's own + * instruction rather than an inference: + * + * {"error":{"message":"This API endpoint has moved. Please update your base_url + * to https://api.freeaiapikey.com/v1 — the old endpoint on freeaiapikey.com no + * longer works.","type":"endpoint_moved","code":"endpoint_moved"}} + * + * Provider entry added in #2708. + */ +const LIVE_API_BASE = "https://api.freeaiapikey.com/v1"; + +/** + * Every model id returned by GET https://api.freeaiapikey.com/v1/models on 2026-08-13. + * The response carries only id/object/created/owned_by — upstream publishes no context + * window, so models catalogued from it declare no contextLength and inherit the entry's + * defaultContextLength rather than an invented number. + */ +const LIVE_MODEL_IDS = [ + "openai/gpt-4o", + "openai/gpt-5.4", + "openai/gpt-5.5", + "openai/gpt-5.6-sol", + "anthropic/claude-opus-4.6", + "anthropic/claude-opus-4.7", + "anthropic/claude-opus-4.8", + "anthropic/claude-sonnet-4.6", + "anthropic/claude-sonnet-5", + "anthropic/claude-opus-5", +]; + +test("freeaiapikey targets the live api. host (upstream 410 endpoint_moved)", () => { + assert.equal( + freeaiapikeyProvider.baseUrl, + `${LIVE_API_BASE}/chat/completions`, + "baseUrl must point at the host named in upstream's 410 endpoint_moved body" + ); + assert.equal( + freeaiapikeyProvider.modelsUrl, + `${LIVE_API_BASE}/models`, + "modelsUrl must point at the host named in upstream's 410 endpoint_moved body" + ); +}); + +test("freeaiapikey keeps no endpoint on the retired freeaiapikey.com apex host", () => { + for (const [field, url] of [ + ["baseUrl", freeaiapikeyProvider.baseUrl], + ["modelsUrl", freeaiapikeyProvider.modelsUrl], + ] as const) { + assert.ok(url, `${field} must be set`); + assert.doesNotMatch( + url, + /^https:\/\/freeaiapikey\.com\//, + `${field} still targets the apex host, which answers 410 endpoint_moved` + ); + } +}); + +test("freeaiapikey catalogs exactly the models upstream serves", () => { + const declared = freeaiapikeyProvider.models.map((model) => model.id); + assert.deepEqual( + [...declared].sort(), + [...LIVE_MODEL_IDS].sort(), + "registry catalog must match the ids returned by the live /v1/models" + ); +}); + +test("freeaiapikey declares no duplicate model ids", () => { + const declared = freeaiapikeyProvider.models.map((model) => model.id); + assert.equal(new Set(declared).size, declared.length, "model ids must be unique"); +}); + +test("freeaiapikey gives every catalogued model a display name", () => { + for (const model of freeaiapikeyProvider.models) { + assert.equal(typeof model.name, "string", `${model.id} must declare a name`); + assert.ok(model.name.length > 0, `${model.id} must declare a non-empty name`); + } +}); + +test("freeaiapikey keeps a provider-wide default for unpublished context windows", () => { + // Upstream reports no context windows, so the models added from its catalog carry + // no contextLength of their own; this default is what they fall back to. + assert.equal( + typeof freeaiapikeyProvider.defaultContextLength, + "number", + "entry must keep a defaultContextLength for models with no upstream-published window" + ); +}); From 326d0e81cb385cdacb83ae014870234c15be99a7 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:42:31 +0200 Subject: [PATCH 30/36] docs(ops): k8s probe recommendations (TCP liveness, HTTP /healthz readiness) (#10297) * docs(ops): recommend TCP liveness and HTTP /healthz readiness for k8s Stock Docker HEALTHCHECK hits /api/monitoring/health (deep). Orchestrators should not use that path for kubelet liveness. Document /healthz vs deep health, note same-process event-loop limits, and link related issues. * docs: add changelog fragment for #10297 --------- Co-authored-by: Ravi Tharuma Co-authored-by: diegosouzapw --- .../10297-k8s-probe-recommendations.md | 1 + docs/guides/DOCKER_GUIDE.md | 17 ++++- docs/ops/MONITORING_GUIDE.md | 70 +++++++++++++++++-- 3 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 changelog.d/maintenance/10297-k8s-probe-recommendations.md diff --git a/changelog.d/maintenance/10297-k8s-probe-recommendations.md b/changelog.d/maintenance/10297-k8s-probe-recommendations.md new file mode 100644 index 0000000000..adc9d4491e --- /dev/null +++ b/changelog.d/maintenance/10297-k8s-probe-recommendations.md @@ -0,0 +1 @@ +- **docs(ops):** document Kubernetes probe recommendations — TCP (or soft HTTP) liveness, HTTP `/healthz` readiness, avoid `/api/monitoring/health` as kubelet liveness ([#10297](https://github.com/diegosouzapw/OmniRoute/pull/10297)) — thanks @RaviTharuma diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 523d87bf77..2fb1b75f79 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -326,7 +326,22 @@ prefix). Traefik should route `PathPrefix(`/omniroute`)` to the container withou `/omniroute/_next/...`. The Docker healthcheck probes `/api/monitoring/health` prefixed with the active -`OMNIROUTE_BASE_PATH`. +`OMNIROUTE_BASE_PATH`. That path is a **deep** check (DB + monitoring summary). It is +appropriate for Docker’s infrequent `HEALTHCHECK`, but **not** for Kubernetes +`livenessProbe` intervals. + +For orchestrators (Kubernetes, Nomad, etc.): + +| Probe | Prefer | Avoid | +| --- | --- | --- | +| Liveness | TCP on the main port (`PORT`, default `20128`), or soft HTTP `/healthz` | `/api/monitoring/health` as liveness | +| Readiness | HTTP `GET /healthz` | Tight timeouts that treat event-loop busy as dead | +| Deep / blackbox | `/api/monitoring/health` | — | + +`/healthz` only reports process lifecycle (`ok` / `starting` / `stopping`). It still +runs on the same Node event loop as request handling, so CPU-bound catalog or +compression work can delay it — busy ≠ dead. Full probe guidance: +[Monitoring guide — Kubernetes probe recommendations](../ops/MONITORING_GUIDE.md#kubernetes-probe-recommendations). ## Docker Compose with Caddy (HTTPS Auto-TLS) diff --git a/docs/ops/MONITORING_GUIDE.md b/docs/ops/MONITORING_GUIDE.md index a9d1db9422..82a66eeb49 100644 --- a/docs/ops/MONITORING_GUIDE.md +++ b/docs/ops/MONITORING_GUIDE.md @@ -1,7 +1,7 @@ --- title: "Monitoring & Observability Guide" -version: 3.8.40 -lastUpdated: 2026-06-28 +version: 3.8.50 +lastUpdated: 2026-08-13 --- # Monitoring & Observability Guide @@ -103,9 +103,29 @@ Per-combo: ## Health Check API -> **Note:** Only `GET /api/monitoring/health` is exposed as a REST endpoint. All other monitoring data (provider health, autopilot issues, quota monitors, token health, latency) is accessed via the **MCP tool** `observability_snapshot` or the **dashboard** pages — there are no dedicated REST routes for these. +OmniRoute exposes **two** HTTP health surfaces. They are not interchangeable for orchestrators. -### System Health +| Path | Purpose | Weight | Use for | +| --- | --- | --- | --- | +| `GET /healthz` | Lifecycle liveness/readiness (`ok` / `starting` / `stopping`) | Trivial (phase flag only) | Kubernetes **readiness**; soft **liveness** if you must use HTTP | +| `GET /api/monitoring/health` | Deep system + provider summary (DB, heap, catalog counts, …) | Heavy (sync DB / monitoring work) | Dashboards, blackbox deep checks, Docker’s built-in healthcheck | + +> **Note:** Provider health matrices, autopilot issues, quota monitors, token health, and latency detail beyond `/api/monitoring/health` are available via the **MCP tool** `observability_snapshot` or the **dashboard** pages — there are no dedicated REST routes for those. + +Both routes run on the **same Node event loop** as request handling. A CPU-bound path (large `GET /v1/models` catalog work, long-context compression / token counting) can delay **all** HTTP handlers, including `/healthz`. Event-loop busy ≠ process dead. Prefer fixing the hog; probe tuning only reduces false kills. + +### Lightweight orchestrator probe + +```bash +GET /healthz +# or HEAD /healthz +``` + +- **200** + body `ok` when the server lifecycle phase is ready +- **503** + `starting` / `stopping` during boot or shutdown +- Implementation: `src/app/healthz/route.ts` (no DB ping) + +### System Health (deep) ```bash GET /api/monitoring/health @@ -135,6 +155,48 @@ Response: } ``` +### Kubernetes probe recommendations + +OmniRoute is a **single Node process** (one event loop). Stock Docker `HEALTHCHECK` targets `/api/monitoring/health` — that is **too heavy** for kubelet liveness intervals. + +| Probe | Recommended target | Notes | +| --- | --- | --- | +| **Startup** | HTTP `GET /healthz` with a long `failureThreshold` (or large `startPeriod`) | Cold start + SQLite migration can exceed a few seconds | +| **Readiness** | HTTP `GET /healthz` | Remove endpoints while starting/stopping; still flaps if the loop is CPU-blocked | +| **Liveness** | **TCP** on the main service port (`PORT`, default `20128`), **or** HTTP `/healthz` with soft thresholds | Do **not** kill the pod on short event-loop stalls; busy ≠ dead | +| **Deep health** | `GET /api/monitoring/health` from an external checker | Not for kubelet `livenessProbe` / tight `readinessProbe` | + +Example shape (adjust thresholds to your cold-start and compression load): + +```yaml +ports: + - name: http + containerPort: 20128 +startupProbe: + httpGet: + path: /healthz + port: http + failureThreshold: 30 + periodSeconds: 5 +readinessProbe: + httpGet: + path: /healthz + port: http + periodSeconds: 5 + timeoutSeconds: 2 + failureThreshold: 6 +livenessProbe: + tcpSocket: + port: http + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 +``` + +**Do not** point kubelet **liveness** at `/api/monitoring/health`. That path does real DB/monitoring work and will false-positive under load. + +Related: [#10052](https://github.com/diegosouzapw/OmniRoute/issues/10052) (probes while the event loop is busy), [#9685](https://github.com/diegosouzapw/OmniRoute/issues/9685) / [#10055](https://github.com/diegosouzapw/OmniRoute/pull/10055) (catalog pricing hog), [#10117](https://github.com/diegosouzapw/OmniRoute/issues/10117) (compression token-count hog). + ### Provider Health > **No REST endpoint.** Provider health data is available via the MCP tool `observability_snapshot` or the dashboard `/dashboard/providers` page. From 4c7b902257c162971aafece31b33fe1319700ac1 Mon Sep 17 00:00:00 2001 From: Ravi Tharuma <25951435+RaviTharuma@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:42:36 +0200 Subject: [PATCH 31/36] fix(ops): Docker HEALTHCHECK probes /healthz not deep monitoring (#10307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * Hide health-check excluded models from /v1/models catalog (#10026) Mirror the request-time exclusion rule (provider_specific_data.excludedModels) in the unified catalog builder: a model is hidden when its provider has connections but none of them is eligible for it. Applied across the PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops so ghost models no longer appear as available. Co-authored-by: ritheshcn25 * fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055) * fix(models): memoize getModelsDevPricing for /v1/models catalog resolveCatalogPricing called getModelsDevPricing once per model while building GET /v1/models. Each call re-scanned models_dev_pricing and JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging the event loop so even /healthz timed out (#9685, #10052). Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing and add a unit test for invalidation. Signed-off-by: Ravi Tharuma * fix(db): invalidate modelsDevPricing cache on DB reset (#10055) Copilot review fixes: 1. Register invalidateModelsDevPricingCache() with DB state reset system so resetDbInstance() clears the process-local memo, preventing stale pricing data from surviving across DB reset/restore operations. 2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055). The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing() results until saveModelsDevPricing()/clearModelsDevPricing() to avoid re-scanning all pricing rows on every /v1/models request. Without this hook, backup restore and test DB resets would serve stale cached data from the previous connection. Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts --------- Signed-off-by: Ravi Tharuma Co-authored-by: Ravi Tharuma Co-authored-by: Cursor Agent * fix(ops): Docker HEALTHCHECK probes /healthz not deep monitoring /api/monitoring/health does a SQLite ping and more. When the event loop is busy the official image HEALTHCHECK (5s timeout) marks the container Unhealthy and orchestrators restart the only replica mid-session. * fix(ops): keep healthcheck PR scoped to the /healthz probe Drop the stray catalog ghost-model exclusion that leaked into this branch from main (already covered upstream). Restore catalog.ts to the release version so the PR contains only the Docker HEALTHCHECK /healthz fix, its tests, and the changelog entry. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Signed-off-by: Ravi Tharuma Co-authored-by: diegosouzapw Co-authored-by: Diego Rodrigues de Sa e Souza Co-authored-by: diegosouzapw Co-authored-by: ritheshcn25 Co-authored-by: ritheshcn25 Co-authored-by: Ravi Tharuma Co-authored-by: Cursor Agent Co-authored-by: adevwithpurpose Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- changelog.d/fixes/docker-healthcheck-use-healthz.md | 1 + scripts/dev/healthcheck.mjs | 7 +++++-- tests/unit/docker-healthcheck-3151.test.ts | 2 +- tests/unit/docker-healthcheck-base-path.test.ts | 8 ++++---- 4 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/docker-healthcheck-use-healthz.md diff --git a/changelog.d/fixes/docker-healthcheck-use-healthz.md b/changelog.d/fixes/docker-healthcheck-use-healthz.md new file mode 100644 index 0000000000..a139e2dd4c --- /dev/null +++ b/changelog.d/fixes/docker-healthcheck-use-healthz.md @@ -0,0 +1 @@ +- **fix(ops):** Docker HEALTHCHECK probes lightweight `/healthz` instead of `/api/monitoring/health` so a busy event loop does not mark the container Unhealthy (`scripts/dev/healthcheck.mjs`) diff --git a/scripts/dev/healthcheck.mjs b/scripts/dev/healthcheck.mjs index c65b0957b1..6124b83a81 100644 --- a/scripts/dev/healthcheck.mjs +++ b/scripts/dev/healthcheck.mjs @@ -2,7 +2,10 @@ /** * Docker healthcheck script for OmniRoute. - * Probes the /api/monitoring/health endpoint on the dashboard port. + * Probes the lightweight /healthz endpoint on the dashboard port. + * /api/monitoring/health is the deep human/dashboard check (SQLite ping); + * using it as Docker HEALTHCHECK marks the container Unhealthy whenever the + * event loop is busy (#10052) and can restart the only replica mid-session. * Used by Dockerfile and docker-compose files. * * #3151 — in some Docker network setups the server binds to a container IP and @@ -21,7 +24,7 @@ import { networkInterfaces } from "node:os"; const DEFAULT_HOSTS = ["127.0.0.1", "localhost", "::1"]; const DEFAULT_TIMEOUT_MS = 4000; -const DEFAULT_HEALTH_PATH = "/api/monitoring/health"; +const DEFAULT_HEALTH_PATH = "/healthz"; function normalizeBasePath(value) { const trimmed = typeof value === "string" ? value.trim() : ""; diff --git a/tests/unit/docker-healthcheck-3151.test.ts b/tests/unit/docker-healthcheck-3151.test.ts index a8717d521d..945c516302 100644 --- a/tests/unit/docker-healthcheck-3151.test.ts +++ b/tests/unit/docker-healthcheck-3151.test.ts @@ -19,7 +19,7 @@ const { probeHealth } = (await import("../../scripts/dev/healthcheck.mjs")) as { function startServer(host: string): Promise<{ server: http.Server; port: number }> { return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { - if (req.url === "/api/monitoring/health") { + if (req.url === "/healthz") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ status: "ok" })); } else { diff --git a/tests/unit/docker-healthcheck-base-path.test.ts b/tests/unit/docker-healthcheck-base-path.test.ts index a49b548a55..1ac2d6ba85 100644 --- a/tests/unit/docker-healthcheck-base-path.test.ts +++ b/tests/unit/docker-healthcheck-base-path.test.ts @@ -3,11 +3,11 @@ import assert from "node:assert/strict"; import { resolveHealthPath } from "../../scripts/dev/healthcheck.mjs"; test("resolveHealthPath keeps the default route at the domain root", () => { - assert.equal(resolveHealthPath(""), "/api/monitoring/health"); - assert.equal(resolveHealthPath(undefined), "/api/monitoring/health"); + assert.equal(resolveHealthPath(""), "/healthz"); + assert.equal(resolveHealthPath(undefined), "/healthz"); }); test("resolveHealthPath prefixes the health route with OMNIROUTE_BASE_PATH", () => { - assert.equal(resolveHealthPath("/omniroute/"), "/omniroute/api/monitoring/health"); - assert.equal(resolveHealthPath("/omniroute"), "/omniroute/api/monitoring/health"); + assert.equal(resolveHealthPath("/omniroute/"), "/omniroute/healthz"); + assert.equal(resolveHealthPath("/omniroute"), "/omniroute/healthz"); }); From e1739fc71d382d9f19d5fbd9a13a625db3c09948 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sun, 16 Aug 2026 00:42:42 -0300 Subject: [PATCH 32/36] fix(security): sanitize test regex and annotate CodeQL hash false-positives (#10380) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(security): sanitize test regex and annotate CodeQL hash false-positives tests/unit/early-sse-route-intent.test.ts built a RegExp from a hardcoded string but only escaped `?`/`.`, missing `\` — js/incomplete-sanitization (#816). Not exploitable (fixed literal input) but the escaping was genuinely incomplete; now escapes backslash too. reasoningCache.ts::buildAssistantMessageCacheKey and codexIdentity.ts's two UUID derivation helpers hash a cache-scope/account-seed with SHA-256 to produce a lookup key / deterministic ID — not a stored, verified password. CodeQL's js/insufficient-password-hash overfires on any hash of a secret-like variable, the same false-positive class already annotated at src/lib/db/apiKeys.ts:624. Added matching lgtm/nosemgrep annotations and inline rationale so the intent is clear to reviewers and future scans. Refs #815 #816 #817 #818 * fix(security): keep only the regex sanitization; drop non-functional CodeQL annotations The lgtm[]/nosemgrep: comments in codexIdentity.ts and reasoningCache.ts use formats GitHub Actions CodeQL does not honor, and shifting those sha256 lines re-attributed the already-dismissed base alerts to this PR as two new CodeQL findings. Revert those two annotation-only files to base so the existing dismissals apply; retain the real fix (escaping backslash in the test regex), which resolves the open js/incomplete-sanitization alert. --------- Co-authored-by: Xiangzhe Co-authored-by: adevwithpurpose --- tests/unit/early-sse-route-intent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/early-sse-route-intent.test.ts b/tests/unit/early-sse-route-intent.test.ts index b8b48918fc..486b176831 100644 --- a/tests/unit/early-sse-route-intent.test.ts +++ b/tests/unit/early-sse-route-intent.test.ts @@ -22,7 +22,7 @@ const ROUTES = [ for (const route of ROUTES) { test(`${route.name} early-heartbeat gate uses the real stream resolver`, () => { - const escapedBodyExpression = route.bodyExpression.replace(/[?.]/g, "\\$&"); + const escapedBodyExpression = route.bodyExpression.replace(/[.?\\]/g, "\\$&"); assert.match( route.source, new RegExp( From 6b85413b87636cbd3bfb2ced569ffc33a0466019 Mon Sep 17 00:00:00 2001 From: backryun Date: Sun, 16 Aug 2026 12:42:48 +0900 Subject: [PATCH 33/36] perf(electron): build the Next standalone once and hydrate natives per leg (#10321 stage 8) (#10390) The desktop release matrix ran the full Next.js standalone build on all four legs (windows, macos-intel, macos-arm64, linux), duplicating the platform-neutral majority of that work four times and re-exposing every leg to the hosted-runner RAM class of failure that took the linux leg out of v3.8.49. - scripts/build/standaloneTarball.mjs: deterministic, dependency-free tar.gz writer/reader (uid/gid/mtime pinned, sorted entries, symlink + exec-bit preservation; GNU-tar interop covered by tests). - scripts/build/standaloneManifest.mjs: byte-level manifest of .build/next (sha256 + size + symlink target per entry, plus the archive's own digest) catching artifact-transfer corruption before extraction and re-verifying the restored tree byte-for-byte, smuggling included. - scripts/build/standaloneBundle.mjs: pack / restore / hydrate CLI over the two modules above. - scripts/build/hydrateNativeDeps.mjs: swaps install-machine-forked native optionals (@img/sharp-*, @ngrok/ngrok-*, fsevents) from the leg's own npm ci into the restored tree, then verifies the bundled-native closure (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime with its documented darwin-x64 exemption) services the leg's platform/arch before packaging starts. - .github/workflows/electron-release.yml: new web-build job builds the standalone once on ubuntu with webpack and uploads the bundle; legs download, restore, and hydrate it, skipping the per-leg build. The legacy per-leg build remains as a rollback path via the ELECTRON_SHARED_STANDALONE workflow_dispatch input, and legs fail closed if web-build ran and failed. Regression tests cover archive roundtrip, byte determinism, manifest tamper/smuggle detection, forked-native swaps, and native-closure serviceability. --- .github/workflows/electron-release.yml | 106 +++++- scripts/build/hydrateNativeDeps.mjs | 137 ++++++++ scripts/build/standaloneBundle.mjs | 221 ++++++++++++ scripts/build/standaloneManifest.mjs | 132 +++++++ scripts/build/standaloneTarball.mjs | 381 +++++++++++++++++++++ tests/unit/build/standalone-bundle.test.ts | 304 ++++++++++++++++ 6 files changed, 1279 insertions(+), 2 deletions(-) create mode 100644 scripts/build/hydrateNativeDeps.mjs create mode 100644 scripts/build/standaloneBundle.mjs create mode 100644 scripts/build/standaloneManifest.mjs create mode 100644 scripts/build/standaloneTarball.mjs create mode 100644 tests/unit/build/standalone-bundle.test.ts diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index fdc1a29d29..33708fc426 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -55,9 +55,75 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "✓ Valid version: $VERSION" + web-build: + name: Build shared Next standalone + needs: validate + # Stage 8 (issue #10321): the four desktop legs used to each run the full + # `npm run build` (Next standalone) — ~111 runner-minutes per release just to + # produce the same platform-independent bundle four times. This job builds it + # once on ubuntu; every leg then restores the byte-verified archive and + # re-forks its native optionals (scripts/build/standaloneBundle.mjs). + # + # Rollback lever: set the repo variable ELECTRON_SHARED_STANDALONE=disabled. + # This job then skips, every leg falls back to building its own web bundle + # (the legacy step below), and the pipeline behaves exactly like pre-Stage 8 — + # no revert needed. + if: ${{ !cancelled() && needs.validate.result == 'success' && vars.ELECTRON_SHARED_STANDALONE != 'disabled' }} + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Install dependencies + run: npm ci + env: + NPM_CONFIG_LEGACY_PEER_DEPS: true + + - name: Build Next.js standalone + # webpack, not Turbopack, for the same hosted-runner RAM reason as the + # linux leg (see the long comment on the fallback step in `build`). + env: + JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation + NODE_OPTIONS: "--max_old_space_size=6144" + OMNIROUTE_USE_TURBOPACK: "0" + run: npm run build + + - name: Pack standalone bundle + # Deterministic tar.gz + byte-level manifest; the manifest embeds the + # archive's own sha256 so artifact-transfer corruption is caught before + # extraction, and every entry is re-verified after extraction. + run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz + + - name: Upload shared web bundle + uses: actions/upload-artifact@v7 + with: + name: web-standalone-bundle + # compression-level 0: the payload is already a deterministic tar.gz; + # re-zipping would only burn runner CPU without shrinking it further. + compression-level: 0 + # Legs consume this within minutes; no reason to retain it like the + # installer artifacts (default 90d). + retention-days: 3 + path: | + web-bundle.tar.gz + web-bundle.tar.gz.manifest.json + build: name: Build Electron (${{ matrix.platform }}) - needs: validate + needs: [validate, web-build] + # `web-build` is skipped when ELECTRON_SHARED_STANDALONE=disabled (rollback + # mode); legs then run the legacy per-leg web build below. If it ran and + # failed, fail closed: legs cannot package without the bundle, and silently + # falling back to four per-leg builds would hide exactly the regression the + # shared job exists to surface. + if: ${{ !cancelled() && needs.validate.result == 'success' && (needs.web-build.result == 'success' || needs.web-build.result == 'skipped') }} runs-on: ${{ matrix.runner }} permissions: contents: write # electron-builder may publish artifacts with GH_TOKEN @@ -69,19 +135,27 @@ jobs: runner: windows-latest target: win ext: .exe + os: win32 + arch: x64 - platform: macos-intel runner: macos-15-intel target: mac-x64 ext: .dmg + os: darwin + arch: x64 - platform: macos-arm64 runner: macos-latest target: mac-arm64 ext: -arm64.dmg + os: darwin + arch: arm64 - platform: linux runner: ubuntu-latest target: linux ext: .AppImage deb_ext: .deb + os: linux + arch: x64,arm64 steps: - uses: actions/checkout@v7 @@ -108,7 +182,11 @@ jobs: mkdir -p "$RUNNER_TEMP/home" echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV" - - name: Build Next.js standalone + - name: Build Next.js standalone (legacy per-leg fallback) + # Stage 8: only runs in rollback mode (ELECTRON_SHARED_STANDALONE=disabled) + # or when the shared web-build job was skipped. Otherwise the leg restores + # the shared bundle from the `web-build` job below. + if: needs.web-build.result == 'skipped' env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation NODE_OPTIONS: "--max_old_space_size=6144" @@ -126,6 +204,30 @@ jobs: OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }} run: npm run build + - name: Download shared web bundle + # Stage 8: inverse of the fallback step above — runs exactly when the + # shared `web-build` job produced the bundle. + if: needs.web-build.result == 'success' + uses: actions/download-artifact@v8 + with: + name: web-standalone-bundle + + - name: Restore + hydrate shared web bundle + if: needs.web-build.result == 'success' + shell: bash + # restore: verify the archive's sha256 against the manifest, extract, then + # re-verify every entry (existence + size + content hash + symlink + # targets, and no unlisted files) byte-for-byte. + # hydrate: the bundle was built on ubuntu, so install-machine-forked native + # optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*, + # fsevents) carry linux forks. Replace them with the forks this + # leg's own `npm ci` resolved, then assert every bundled native + # (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime) + # can service this leg's platform/arch before packaging starts. + run: | + node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz + node scripts/build/standaloneBundle.mjs hydrate --platform ${{ matrix.os }} --arch ${{ matrix.arch }} + - name: Sync version in electron/package.json shell: bash env: diff --git a/scripts/build/hydrateNativeDeps.mjs b/scripts/build/hydrateNativeDeps.mjs new file mode 100644 index 0000000000..4b7d4a2f9a --- /dev/null +++ b/scripts/build/hydrateNativeDeps.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Platform hydration for the shared Next standalone web build (issue #10321, + * Stage 8). + * + * The standalone bundle is built ONCE on ubuntu and restored on every desktop + * matrix leg. Everything except install-machine-forked optional packages is + * platform-independent: + * + * - Bundled-for-all (verify only): koffi ships every triplet under + * `build/koffi/_`, better-sqlite3 v13 ships Node-API prebuilds for + * 8 platforms, wreq-js ships `rust/wreq-js.-[-libc].node`, and + * onnxruntime-node ships `bin/napi-v6//`. + * - Install-machine-forked (hydrate): `@img/sharp-*`, `@img/sharp-libvips-*`, + * `@ngrok/ngrok-*` and macOS-only `fsevents` resolve to whichever platform + * ran `npm ci`. The ubuntu-built tree carries the linux forks; each leg + * replaces them with the forks from its OWN `npm ci`d node_modules. + */ + +import fs from "node:fs"; +import path from "node:path"; + +/** Scope prefixes whose members are install-machine-forked. */ +export const HYDRATED_SCOPES = ["@img/sharp-", "@img/sharp-libvips-", "@ngrok/ngrok-"]; + +/** Standalone packages that are not forked but must never be platform-forked. */ +export const HYDRATED_ROOT_PACKAGES = ["fsevents"]; + +/** + * onnxruntime-node does not publish a darwin-x64 binary for napi-v6 (only + * linux/win32 x64 + darwin arm64), so existence cannot be asserted there. + */ +export const BUNDLED_EXEMPTIONS = new Set(["onnxruntime-node:darwin-x64"]); + +function platformTriple(platform, arch) { + // koffi uses underscore triplets; better-sqlite3/wreq-js/onnx use dashes. + return { koffi: `${platform}_${arch}`, dash: `${platform}-${arch}` }; +} + +function rmrf(target) { + fs.rmSync(target, { recursive: true, force: true }); +} + +function copyDir(from, to) { + fs.cpSync(from, to, { recursive: true, verbatimSymlinks: false, force: true }); +} + +function directMemberNames(nodeModulesDir, scope) { + const scopeDir = path.join(nodeModulesDir, ...scope.split("/").slice(0, -1)); + const prefix = scope.split("/").pop(); + try { + return fs + .readdirSync(scopeDir) + .filter((name) => name.startsWith(prefix)) + .map((name) => `${scope.slice(0, scope.lastIndexOf("/"))}/${name}`); + } catch { + return []; + } +} + +/** + * Replace install-machine-forked packages inside the restored standalone tree + * with the forks resolved by THIS machine's node_modules. + * + * @param {{standaloneNodeModules: string, sourceNodeModules: string}} opts + * @returns {{replaced: string[], removed: string[], copied: string[]}} + */ +export function hydratePlatformNatives({ standaloneNodeModules, sourceNodeModules }) { + const replaced = []; + const removed = []; + const copied = []; + + const forkedNames = new Set(); + for (const scope of HYDRATED_SCOPES) { + for (const name of directMemberNames(sourceNodeModules, scope)) forkedNames.add(name); + for (const name of directMemberNames(standaloneNodeModules, scope)) forkedNames.add(name); + } + for (const pkg of HYDRATED_ROOT_PACKAGES) { + if (fs.existsSync(path.join(sourceNodeModules, pkg))) forkedNames.add(pkg); + if (fs.existsSync(path.join(standaloneNodeModules, pkg))) forkedNames.add(pkg); + } + + for (const name of forkedNames) { + const standalonePath = path.join(standaloneNodeModules, ...name.split("/")); + const sourcePath = path.join(sourceNodeModules, ...name.split("/")); + const hadIt = fs.existsSync(standalonePath); + const hasIt = fs.existsSync(sourcePath); + if (hadIt) rmrf(standalonePath); + if (!hasIt) { + if (hadIt) removed.push(name); + continue; // e.g. fsevents on non-darwin legs: simply absent everywhere. + } + copyDir(sourcePath, standalonePath); + copied.push(name); + if (hadIt) replaced.push(name); + } + return { replaced, removed, copied }; +} + +/** + * Assert that every bundled native dependency can service `platform`/`arch`. + * + * @returns {{ok: true} | {ok: false, errors: string[]}} + */ +export function verifyBundledNatives({ nodeModulesDir, platform, arch }) { + const errors = []; + const triple = platformTriple(platform, arch); + + const koffiDir = path.join(nodeModulesDir, "koffi", "build", "koffi", triple.koffi); + if (!fs.existsSync(koffiDir)) errors.push(`koffi: missing bundled triplet ${triple.koffi}`); + + const sqlitePrebuild = path.join( + nodeModulesDir, + "better-sqlite3", + "prebuilds", + `${triple.dash}.node` + ); + if (!fs.existsSync(sqlitePrebuild)) + errors.push(`better-sqlite3: missing prebuild ${triple.dash}.node`); + + const wreqDir = path.join(nodeModulesDir, "wreq-js", "rust"); + const wreqNames = fs.existsSync(wreqDir) + ? fs + .readdirSync(wreqDir) + .filter((n) => n.startsWith(`wreq-js.${triple.dash}`) && n.endsWith(".node")) + : []; + if (wreqNames.length === 0) errors.push(`wreq-js: missing rust binary for ${triple.dash}`); + + const exempt = BUNDLED_EXEMPTIONS.has(`onnxruntime-node:${triple.dash}`); + if (!exempt) { + const onnxDir = path.join(nodeModulesDir, "onnxruntime-node", "bin", "napi-v6", platform, arch); + if (!fs.existsSync(onnxDir)) + errors.push(`onnxruntime-node: missing ${platform}/${arch} binary`); + } + + return errors.length === 0 ? { ok: true } : { ok: false, errors }; +} diff --git a/scripts/build/standaloneBundle.mjs b/scripts/build/standaloneBundle.mjs new file mode 100644 index 0000000000..6e440e34a8 --- /dev/null +++ b/scripts/build/standaloneBundle.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * CLI entry for the shared Next standalone web build (issue #10321, Stage 8). + * + * One ubuntu `web-build` job runs `pack` once; every desktop matrix leg runs + * `restore` (byte-verified against the manifest) and `hydrate` (replaces + * install-machine-forked native optionals with this leg's own `npm ci` forks, + * then asserts the bundled natives can service the leg's platform/arch). + * + * Rollback: set repo variable ELECTRON_SHARED_STANDALONE=disabled and the + * workflow falls back to the legacy per-leg `npm run build` — no revert needed. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { + buildStandaloneManifest, + verifyStandaloneManifest, + MANIFEST_VERSION, +} from "./standaloneManifest.mjs"; +import { createTarGz, extractTarGz } from "./standaloneTarball.mjs"; +import { hydratePlatformNatives, verifyBundledNatives } from "./hydrateNativeDeps.mjs"; + +function sha256File(filePath) { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(filePath); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("error", reject); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +function manifestPathFor(archive) { + return `${archive}.manifest.json`; +} + +/** + * Pack a web-build tree into a deterministic archive plus a byte-level + * manifest (which embeds the archive's own sha256 so transfer corruption is + * caught before extraction). + * + * @param {{dir?: string, out: string, manifest?: string}} opts + * @returns {Promise<{archive: string, manifest: string, files: number, archiveBytes: number}>} + */ +export async function runPack({ dir = ".build/next", out, manifest }) { + if (!out) throw new Error("pack requires --out "); + const rootDir = path.resolve(dir); + if (!fs.existsSync(rootDir)) { + throw new Error(`web build tree not found: ${rootDir} (did 'npm run build' run?)`); + } + fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true }); + + const built = await buildStandaloneManifest(rootDir); + await createTarGz(rootDir, out); + const archiveBytes = fs.statSync(out).size; + const archiveSha = await sha256File(out); + + const manifestFile = manifest ?? manifestPathFor(out); + const payload = { + version: MANIFEST_VERSION, + archive: { name: path.basename(out), bytes: archiveBytes, sha256: archiveSha }, + entries: built.entries, + }; + fs.writeFileSync(manifestFile, `${JSON.stringify(payload, null, 2)}\n`); + return { archive: out, manifest: manifestFile, files: built.entries.length, archiveBytes }; +} + +/** + * Verify + extract a packed archive into `dir`, then prove the restored tree + * matches the manifest byte-for-byte. + * + * @param {{archive: string, manifest?: string, dir?: string}} opts + * @returns {Promise<{archive: string, dir: string, files: number}>} + */ +export async function runRestore({ archive, manifest, dir = ".build/next" }) { + if (!archive) throw new Error("restore requires --archive "); + const manifestFile = manifest ?? manifestPathFor(archive); + const raw = JSON.parse(fs.readFileSync(manifestFile, "utf8")); + if (raw.version !== MANIFEST_VERSION) { + throw new Error(`unsupported manifest version: ${raw.version}`); + } + + const archiveBytes = fs.statSync(archive).size; + if (archiveBytes !== raw.archive.bytes) { + throw new Error(`archive size ${archiveBytes} != manifest ${raw.archive.bytes}`); + } + const archiveSha = await sha256File(archive); + if (archiveSha !== raw.archive.sha256) { + throw new Error(`archive sha256 mismatch (expected ${raw.archive.sha256.slice(0, 12)})`); + } + + const destDir = path.resolve(dir); + fs.rmSync(destDir, { recursive: true, force: true }); + await extractTarGz(archive, destDir); + + const verdict = await verifyStandaloneManifest(destDir, raw); + if (!verdict.ok) { + throw new Error( + `restored tree failed manifest verification:\n ${verdict.errors.join("\n ")}` + ); + } + return { archive, dir: destDir, files: raw.entries.length }; +} + +/** + * Hydrate the restored tree's node_modules with this machine's forked + * optionals and assert bundled natives cover every requested arch. + * + * @param {{standaloneNodeModules?: string, sourceNodeModules?: string, platform: string, arch: string}} opts + * `arch` accepts a comma-separated list (the linux leg ships x64+arm64). + * @returns {Promise<{replaced: string[], removed: string[], copied: string[], verified: string[]}>} + */ +export async function runHydrate({ + standaloneNodeModules = ".build/next/standalone/node_modules", + sourceNodeModules = "node_modules", + platform, + arch, +}) { + if (!platform || !arch) throw new Error("hydrate requires --platform --arch "); + const result = hydratePlatformNatives({ + standaloneNodeModules: path.resolve(standaloneNodeModules), + sourceNodeModules: path.resolve(sourceNodeModules), + }); + const verified = []; + for (const one of arch + .split(",") + .map((s) => s.trim()) + .filter(Boolean)) { + const verdict = verifyBundledNatives({ + nodeModulesDir: path.resolve(standaloneNodeModules), + platform, + arch: one, + }); + if (!verdict.ok) { + throw new Error( + `bundled natives cannot service ${platform}/${one}:\n ${verdict.errors.join("\n ")}` + ); + } + verified.push(one); + } + return { ...result, verified }; +} + +// ─── argv plumbing ─────────────────────────────────────────────────────────────── + +/** Minimal `--key value` parser (booleans: `--key` alone → true). */ +export function parseArgs(argv) { + const opts = { _: [] }; + for (let i = 0; i < argv.length; i++) { + const token = argv[i]; + if (!token.startsWith("--")) { + opts._.push(token); + continue; + } + const key = token.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith("--")) { + opts[key] = next; + i++; + } else { + opts[key] = true; + } + } + return opts; +} + +function usage() { + return [ + "usage:", + " standaloneBundle.mjs pack --out [--dir .build/next] [--manifest ]", + " standaloneBundle.mjs restore --archive [--manifest ] [--dir .build/next]", + " standaloneBundle.mjs hydrate --platform --arch ", + " [--standalone-node-modules ] [--source-node-modules ]", + ].join("\n"); +} + +async function main(argv) { + const [command = "", ...rest] = argv; + const opts = parseArgs(rest); + try { + if (command === "pack") { + const r = await runPack({ dir: opts.dir, out: opts.out, manifest: opts.manifest }); + console.log( + `[standalone-bundle] packed ${r.files} entries -> ${r.archive} ` + + `(${(r.archiveBytes / 1e6).toFixed(1)} MB); manifest ${r.manifest}` + ); + } else if (command === "restore") { + const r = await runRestore({ archive: opts.archive, manifest: opts.manifest, dir: opts.dir }); + console.log( + `[standalone-bundle] restored ${r.files} entries from ${path.basename(r.archive)} -> ${r.dir}` + ); + } else if (command === "hydrate") { + const r = await runHydrate({ + standaloneNodeModules: opts["standalone-node-modules"], + sourceNodeModules: opts["source-node-modules"], + platform: opts.platform, + arch: opts.arch, + }); + console.log( + `[standalone-bundle] hydrated forks: copied=${r.copied.length} replaced=${r.replaced.length} ` + + `removed=${r.removed.length}; bundled natives verified for ${r.verified.join("+")}` + ); + } else { + console.error(usage()); + process.exitCode = 2; + } + } catch (err) { + console.error(`[standalone-bundle] ${command || "(no command)"} failed: ${err.message}`); + process.exitCode = 1; + } +} + +if ( + process.argv[1] && + import.meta.url === new URL(`file://${path.resolve(process.argv[1])}`).href +) { + await main(process.argv.slice(2)); +} diff --git a/scripts/build/standaloneManifest.mjs b/scripts/build/standaloneManifest.mjs new file mode 100644 index 0000000000..19a3eb8288 --- /dev/null +++ b/scripts/build/standaloneManifest.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node +/** + * Byte-level manifest for the shared Next standalone web build (issue #10321, + * Stage 8). + * + * The desktop pipeline used to rebuild the identical Next standalone bundle + * four times (one per electron-release matrix leg). Stage 8 builds it once on + * an ubuntu runner and restores it on every leg; this module is the integrity + * contract that makes a restored tree provably identical to the built one. + * + * Deterministic by construction: entries are sorted by path, timestamps are + * never recorded, and symlinks are pinned by their target so a restored tree + * verifies even though tar extraction rewrites mtimes. + */ + +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import fs from "node:fs"; +import path from "node:path"; + +export const MANIFEST_VERSION = 1; + +/** Streamed sha256 for large native payloads (onnxruntime is ~200 MB). */ +async function sha256File(filePath) { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(filePath); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("error", reject); + stream.on("end", () => resolve(hash.digest("hex"))); + }); +} + +function walkDir(root, current, entries) { + const children = fs.readdirSync(current, { withFileTypes: true }); + // Sort for determinism: manifest of the same tree is byte-identical. + children.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const child of children) { + const abs = path.join(current, child.name); + const rel = path.relative(root, abs).split(path.sep).join("/"); + if (child.isSymbolicLink()) { + entries.push({ path: rel, symlink: fs.readlinkSync(abs) }); + } else if (child.isDirectory()) { + walkDir(root, abs, entries); + } else if (child.isFile()) { + entries.push({ path: rel, file: abs }); + } + // Other node types (fifo/socket) never appear in build output; ignoring + // them keeps the manifest shape minimal. + } +} + +/** + * Build a manifest of every file and symlink under `rootDir`. + * + * @returns {Promise<{version: number, entries: {path: string, bytes: number, sha256: string, symlink?: string}[]}>} + */ +export async function buildStandaloneManifest(rootDir) { + const entries = []; + walkDir(rootDir, rootDir, entries); + const manifestEntries = []; + for (const entry of entries) { + if (entry.symlink !== undefined) { + manifestEntries.push({ path: entry.path, bytes: 0, sha256: "", symlink: entry.symlink }); + continue; + } + const stat = fs.statSync(entry.file); + manifestEntries.push({ + path: entry.path, + bytes: stat.size, + sha256: await sha256File(entry.file), + }); + } + manifestEntries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + return { version: MANIFEST_VERSION, entries: manifestEntries }; +} + +/** + * Verify a restored tree against a manifest built by `buildStandaloneManifest`. + * Checks existence, size, and content hash of every entry, plus that no + * unlisted files were smuggled in. + * + * @returns {Promise<{ok: true} | {ok: false, errors: string[]}>} + */ +export async function verifyStandaloneManifest(rootDir, manifest) { + const errors = []; + if (!manifest || manifest.version !== MANIFEST_VERSION) { + return { ok: false, errors: [`unsupported manifest version: ${manifest?.version}`] }; + } + const listed = new Map(manifest.entries.map((e) => [e.path, e])); + for (const entry of manifest.entries) { + const abs = path.join(rootDir, ...entry.path.split("/")); + let stat; + try { + stat = fs.lstatSync(abs); + } catch { + errors.push(`${entry.path}: missing`); + continue; + } + if (entry.symlink !== undefined) { + if (!stat.isSymbolicLink()) { + errors.push(`${entry.path}: expected symlink, found regular entry`); + } else { + const target = fs.readlinkSync(abs); + if (target !== entry.symlink) { + errors.push(`${entry.path}: symlink target ${target} != ${entry.symlink}`); + } + } + continue; + } + if (!stat.isFile()) { + errors.push(`${entry.path}: expected file, found directory/symlink`); + continue; + } + if (stat.size !== entry.bytes) { + errors.push(`${entry.path}: size ${stat.size} != ${entry.bytes}`); + continue; + } + const digest = await sha256File(abs); + if (digest !== entry.sha256) { + errors.push(`${entry.path}: sha256 mismatch`); + } + } + const actual = []; + walkDir(rootDir, rootDir, actual); + const actualPaths = new Set(actual.map((e) => e.path)); + for (const p of listed.keys()) actualPaths.delete(p); + if (actualPaths.size > 0) { + errors.push(`unlisted files: ${[...actualPaths].sort().slice(0, 5).join(", ")}`); + } + return errors.length === 0 ? { ok: true } : { ok: false, errors }; +} diff --git a/scripts/build/standaloneTarball.mjs b/scripts/build/standaloneTarball.mjs new file mode 100644 index 0000000000..94afbc0334 --- /dev/null +++ b/scripts/build/standaloneTarball.mjs @@ -0,0 +1,381 @@ +#!/usr/bin/env node +/** + * Deterministic tar.gz primitives for the shared web build (issue #10321, + * Stage 8). + * + * Why not shell out to system tar: the restore step runs on every desktop + * matrix leg including Windows, where bsdtar's long-path behavior on deep + * node_modules trees is not guaranteed. Node's fs layer already proves it can + * produce and consume this exact tree on Windows today (the legacy per-leg + * `npm run build` writes it with the same fs), so a pure-Node reader keeps the + * extraction on the one path layer we know works. + * + * Format: ustar with GNU LongLink ('L') entries for paths > 100 chars, + * typeflag '2' for symlinks, mtime/uid/gid zeroed and modes normalized to + * 0644/0755 (exec bit only) so the archive of a given tree is byte-identical + * on every machine. + */ + +import { createReadStream, createWriteStream } from "node:fs"; +import fs from "node:fs"; +import path from "node:path"; +import { once } from "node:events"; +import { createGunzip, createGzip } from "node:zlib"; + +const BLOCK = 512; + +function octal(value, length) { + return value.toString(8).padStart(length - 1, "0") + "\0"; +} + +function headerFor(name, size, typeflag, linkname = "", prefix = "", mode = 0o644) { + const buf = Buffer.alloc(BLOCK, 0); + buf.write(name.slice(0, 100), 0, 100, "utf8"); + buf.write(octal(typeflag === "5" ? 0o755 : mode, 8), 100); + buf.write(octal(0, 8), 108); // uid + buf.write(octal(0, 8), 116); // gid + buf.write(octal(size, 12), 124); + buf.write(octal(0, 12), 136); // mtime = 0 for determinism + buf.write(" ", 148); // checksum placeholder: spaces + buf.write(typeflag, 156); + buf.write(linkname.slice(0, 100), 157, 100, "utf8"); + buf.write("ustar\0", 257, 6, "utf8"); + buf.write("00", 263, 2, "utf8"); + buf.write(prefix.slice(0, 155), 345, 155, "utf8"); + let sum = 0; + for (const byte of buf) sum += byte; + buf.write(sum.toString(8).padStart(6, "0") + "\0 ", 148); + return buf; +} + +function dataPad(size) { + const pad = (BLOCK - (size % BLOCK)) % BLOCK; + return Buffer.alloc(pad, 0); +} + +function longLinkEntry(name) { + const payload = Buffer.from(name + "\0", "utf8"); + return Buffer.concat([ + headerFor("././@LongLink", payload.length, "L"), + payload, + dataPad(payload.length), + ]); +} + +/** Emit header (with LongLink/prefix handling) for one entry. */ +function entryHeader(relPath, size, typeflag, linkname, mode) { + const out = []; + if (relPath.length > 100) { + const slash = relPath.slice(0, 155).lastIndexOf("/"); + const prefix = slash > 0 ? relPath.slice(0, slash) : ""; + const name = prefix ? relPath.slice(slash + 1) : relPath; + if (name.length > 100) { + out.push(longLinkEntry(relPath)); + name = relPath.slice(0, 100); + } + out.push(headerFor(name, size, typeflag, linkname, prefix, mode)); + } else { + out.push(headerFor(relPath, size, typeflag, linkname, undefined, mode)); + } + return Buffer.concat(out); +} + +function* walkFiles(root, current = root) { + const children = fs + .readdirSync(current, { withFileTypes: true }) + .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const child of children) { + const abs = path.join(current, child.name); + const rel = path.relative(root, abs).split(path.sep).join("/"); + if (child.isSymbolicLink()) { + yield { rel, symlink: fs.readlinkSync(abs) }; + } else if (child.isDirectory()) { + yield* walkFiles(root, abs); + } else if (child.isFile()) { + yield { rel, abs }; + } + } +} + +/** Write a buffer, respecting gzip backpressure. */ +async function writeWithBackpressure(stream, buf) { + if (!stream.write(buf)) await once(stream, "drain"); +} + +/** Stream one file's bytes into the archive (no whole-file buffering). */ +function pipeFileInto(gz, failure, abs) { + return new Promise((resolve, reject) => { + const stream = createReadStream(abs, { autoClose: true }); + const onDrain = () => stream.resume(); + const detach = () => gz.removeListener("drain", onDrain); + stream.on("error", (err) => { + detach(); + reject(err); + }); + stream.on("data", (chunk) => { + if (!gz.write(chunk)) stream.pause(); + }); + gz.on("drain", onDrain); + stream.on("end", () => { + detach(); + resolve(); + }); + }); +} + +/** Pack `srcDir` into a deterministic gzipped tarball at `outFile`. */ +export async function createTarGz(srcDir, outFile) { + const out = createWriteStream(outFile); + const gz = createGzip({ level: 1 }); + gz.pipe(out); + + const failure = new Promise((_, reject) => { + gz.on("error", reject); + out.on("error", reject); + }); + + try { + for (const entry of walkFiles(srcDir)) { + if (entry.symlink !== undefined) { + if (entry.symlink.length > 100) { + throw new Error(`symlink target too long for ustar: ${entry.rel} -> ${entry.symlink}`); + } + await writeWithBackpressure(gz, entryHeader(entry.rel, 0, "2", entry.symlink)); + continue; + } + const st = fs.statSync(entry.abs); + const size = st.size; + const mode = st.mode & 0o111 ? 0o755 : 0o644; + await writeWithBackpressure(gz, entryHeader(entry.rel, size, "0", undefined, mode)); + if (size > 0) await Promise.race([pipeFileInto(gz, failure, entry.abs), failure]); + const pad = (BLOCK - (size % BLOCK)) % BLOCK; + if (pad > 0) await writeWithBackpressure(gz, Buffer.alloc(pad, 0)); + } + await writeWithBackpressure(gz, Buffer.alloc(BLOCK * 2, 0)); // terminator + await Promise.race([ + new Promise((resolve, reject) => { + out.on("finish", resolve); + out.on("error", reject); + gz.end(); + }), + failure, + ]); + } catch (err) { + gz.destroy(); + out.destroy(); + throw err; + } +} + +// ─── extraction ────────────────────────────────────────────────────────────────── + +/** + * Promise-based byte source over a gunzip stream. `read(n)` waits until `n` + * bytes are buffered (or EOF); `readSome()` returns whatever is available, for + * streaming large payloads into files without whole-file buffering. + */ +class BlockSource { + constructor(stream) { + this.buffer = Buffer.alloc(0); + this.error = null; + this.ended = false; + this.waiter = null; + stream.on("data", (chunk) => { + this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk]); + this.notify(); + }); + stream.on("end", () => { + this.ended = true; + this.notify(); + }); + stream.on("error", (err) => { + this.error = err; + this.notify(); + }); + } + + notify() { + if (this.waiter) { + const waiter = this.waiter; + this.waiter = null; + waiter(); + } + } + + readSome() { + return new Promise((resolve, reject) => { + const attempt = () => { + if (this.error) return reject(this.error); + if (this.buffer.length > 0) { + const out = this.buffer; + this.buffer = Buffer.alloc(0); + return resolve(out); + } + if (this.ended) return resolve(null); + this.waiter = attempt; + }; + attempt(); + }); + } + + unshift(buf) { + if (buf && buf.length > 0) this.buffer = Buffer.concat([buf, this.buffer]); + } + + async read(n) { + let acc = null; + let remaining = n; + while (remaining > 0) { + const chunk = await this.readSome(); + if (chunk === null) return null; // EOF before n bytes + if (chunk.length > remaining) { + acc = acc + ? Buffer.concat([acc, chunk.subarray(0, remaining)]) + : chunk.subarray(0, remaining); + this.unshift(chunk.subarray(remaining)); + remaining = 0; + } else { + acc = acc ? Buffer.concat([acc, chunk]) : chunk; + remaining -= chunk.length; + } + } + return acc ?? Buffer.alloc(0); + } +} + +function parseOctal(header, offset, length) { + const raw = header.toString("utf8", offset, offset + length).replace(/[\0 ]+$/, ""); + return raw.length === 0 ? 0 : Number.parseInt(raw, 8); +} + +function cstring(header, offset, length) { + const raw = header.toString("utf8", offset, offset + length); + const nul = raw.indexOf("\0"); + return nul === -1 ? raw : raw.slice(0, nul); +} + +function checksumMatches(header) { + const stored = parseOctal(header, 148, 8); + const probe = Buffer.from(header); + probe.fill(" ", 148, 156); // checksum field counts as spaces while summing + let sum = 0; + for (const byte of probe) sum += byte; + return sum === stored; +} + +/** Stream exactly `size` bytes from the reader into `outStream`. */ +async function copyN(reader, size, outStream) { + let remaining = size; + while (remaining > 0) { + const chunk = await reader.readSome(); + if (chunk === null) { + throw new Error(`unexpected EOF after ${size - remaining} of ${size} bytes`); + } + const take = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk; + if (chunk.length > remaining) reader.unshift(chunk.subarray(remaining)); + remaining -= take.length; + if (!outStream.write(take)) await once(outStream, "drain"); + } +} + +/** + * Extract a tarball written by `createTarGz` (ustar + GNU LongLink) into + * `destDir`. Returns the number of entries written. + */ +export async function extractTarGz(archiveFile, destDir) { + fs.mkdirSync(destDir, { recursive: true }); + const src = createReadStream(archiveFile); + const gunzip = createGunzip(); + src.pipe(gunzip); + const reader = new BlockSource(gunzip); + + const zeros = Buffer.alloc(BLOCK); + let longName = null; + let longLink = null; + let entries = 0; + + for (;;) { + const header = await reader.read(BLOCK); + if (header === null) break; // tolerate archives missing the final zero blocks + if (header.equals(zeros)) { + const second = await reader.read(BLOCK); + if (second !== null && !second.equals(zeros)) { + throw new Error("corrupt archive: data after terminator block"); + } + break; + } + if (!checksumMatches(header)) { + throw new Error(`tar header checksum mismatch at entry #${entries + 1}`); + } + + let name = cstring(header, 0, 100); + const size = parseOctal(header, 124, 12); + const typeflag = String.fromCharCode(header[156] || 0x30); + let linkname = cstring(header, 157, 100); + const prefix = cstring(header, 345, 155); + if (prefix) name = `${prefix}/${name}`; + if (longName !== null) { + name = longName; + longName = null; + } + if (longLink !== null) { + linkname = longLink; + longLink = null; + } + + const pad = (BLOCK - (size % BLOCK)) % BLOCK; + + if (typeflag === "L" || typeflag === "K") { + const payload = await reader.read(size); + if (payload === null) throw new Error("unexpected EOF in LongLink payload"); + const value = cstring(payload, 0, payload.length); + if (typeflag === "L") longName = value; + else longLink = value; + if (pad > 0) await reader.read(pad); + continue; + } + + const target = safeJoin(destDir, name); + + if (typeflag === "5") { + fs.mkdirSync(target, { recursive: true }); + } else if (typeflag === "2") { + if (linkname.length === 0) throw new Error(`symlink entry ${name} has empty target`); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.rmSync(target, { force: true }); + fs.symlinkSync(linkname, target); + } else if (typeflag === "1") { + const sourceAbs = safeJoin(destDir, linkname); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(sourceAbs, target); + } else { + // Regular file ("0" or "\0"). The packer never stores directory entries, + // so parent directories are materialized here. + fs.mkdirSync(path.dirname(target), { recursive: true }); + const sink = createWriteStream(target, { flags: "w" }); + const finished = once(sink, "finish"); + sink.on("error", (err) => gunzip.destroy(err)); + await copyN(reader, size, sink); + sink.end(); + await finished; + const storedMode = parseOctal(header, 100, 8); + if (storedMode) fs.chmodSync(target, storedMode); + } + if (pad > 0) { + const skip = await reader.read(pad); + if (skip === null) throw new Error(`unexpected EOF in padding of ${name}`); + } + entries += 1; + } + + src.destroy(); + return { entries }; +} + +function safeJoin(destDir, name) { + const normalized = path.normalize(name).split(path.sep).join("/"); + if (normalized.startsWith("/") || normalized.split("/").includes("..")) { + throw new Error(`unsafe tar entry path: ${name}`); + } + return path.join(destDir, ...normalized.split("/")); +} diff --git a/tests/unit/build/standalone-bundle.test.ts b/tests/unit/build/standalone-bundle.test.ts new file mode 100644 index 0000000000..2444469202 --- /dev/null +++ b/tests/unit/build/standalone-bundle.test.ts @@ -0,0 +1,304 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +/** + * Stage 8 (issue #10321) — shared standalone web bundle. + * + * One ubuntu `web-build` job packs `.build/next` into a deterministic + * archive + byte-level manifest; every desktop leg restores it (verifying + * every entry) and re-forks install-machine-forked native optionals for its + * platform. These tests pin the integrity chain on temp trees: pack/restore + * roundtrip, byte determinism, tamper detection (archive and restored tree), + * manifest-version gating, native fork hydration, and the bundled-native + * serviceability assertion (including the onnxruntime darwin-x64 exemption). + */ + +const bundleMod = await import("../../../scripts/build/standaloneBundle.mjs"); +const manifestMod = await import("../../../scripts/build/standaloneManifest.mjs"); +const hydrateMod = await import("../../../scripts/build/hydrateNativeDeps.mjs"); + +const { runPack, runRestore } = bundleMod as typeof bundleMod & { + runPack: (opts: { dir?: string; out: string; manifest?: string }) => Promise<{ + archive: string; + manifest: string; + files: number; + archiveBytes: number; + }>; + runRestore: (opts: { archive: string; manifest?: string; dir?: string }) => Promise<{ + archive: string; + dir: string; + files: number; + }>; +}; +const { verifyStandaloneManifest, MANIFEST_VERSION } = manifestMod as typeof manifestMod & { + MANIFEST_VERSION: number; + verifyStandaloneManifest: ( + rootDir: string, + manifest: unknown + ) => Promise<{ ok: true } | { ok: false; errors: string[] }>; +}; +const { hydratePlatformNatives, verifyBundledNatives } = hydrateMod as typeof hydrateMod & { + hydratePlatformNatives: (opts: { standaloneNodeModules: string; sourceNodeModules: string }) => { + replaced: string[]; + removed: string[]; + copied: string[]; + }; + verifyBundledNatives: (opts: { nodeModulesDir: string; platform: string; arch: string }) => { + ok: boolean; + errors: string[]; + }; +}; + +const IS_WINDOWS = process.platform === "win32"; + +function tmpDir(prefix: string): string { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function sha256File(filePath: string): string { + return createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +/** Minimal fake `.build/next` tree: nested files, exec bit, and a symlink. */ +function buildWebTree(root: string): void { + const standalone = path.join(root, "standalone"); + fs.mkdirSync(path.join(standalone, "node_modules", "left-pad"), { recursive: true }); + fs.writeFileSync(path.join(standalone, "server.js"), "console.log('omniroute');\n"); + fs.writeFileSync( + path.join(standalone, "node_modules", "left-pad", "index.js"), + "module.exports = (s, n) => String(s).padStart(n);\n" + ); + fs.writeFileSync(path.join(standalone, "node_modules", "left-pad", "package.json"), "{}\n"); + const bin = path.join(standalone, "server-cli.js"); + fs.writeFileSync(bin, "#!/usr/bin/env node\n"); + fs.chmodSync(bin, 0o755); + fs.mkdirSync(path.join(root, "static"), { recursive: true }); + fs.writeFileSync(path.join(root, "static", "app.css"), "body{margin:0}\n"); + if (!IS_WINDOWS) { + fs.symlinkSync("../standalone/server.js", path.join(root, "static", "server-link.js")); + } +} + +function writeNative(root: string, relPath: string, content: string): void { + const target = path.join(root, ...relPath.split("/")); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); +} + +test("pack → restore roundtrip restores the tree byte-for-byte", async () => { + const src = tmpDir("s8-src-"); + const out = path.join(tmpDir("s8-out-"), "web-bundle.tar.gz"); + const dst = tmpDir("s8-dst-"); + try { + buildWebTree(src); + const packed = await runPack({ dir: src, out }); + assert.ok(packed.files > 0, "manifest must list entries"); + assert.ok(fs.existsSync(`${out}.manifest.json`), "manifest written next to archive"); + + const restored = await runRestore({ archive: out, dir: dst }); + assert.equal(restored.files, packed.files); + + assert.equal( + fs.readFileSync(path.join(dst, "standalone", "server.js"), "utf8"), + "console.log('omniroute');\n" + ); + // The restored tree satisfies the manifest (sizes + hashes + symlink targets). + const manifest = JSON.parse(fs.readFileSync(`${out}.manifest.json`, "utf8")); + const verdict = await verifyStandaloneManifest(dst, manifest); + assert.equal( + verdict.ok, + true, + `restored tree must verify: ${verdict.ok ? "" : (verdict as { errors: string[] }).errors.join("; ")}` + ); + if (!IS_WINDOWS) { + assert.equal( + fs.readlinkSync(path.join(dst, "static", "server-link.js")), + "../standalone/server.js", + "symlink target preserved" + ); + assert.equal( + fs.statSync(path.join(dst, "standalone", "server-cli.js")).mode & 0o111, + 0o111, + "exec bit preserved" + ); + } + } finally { + fs.rmSync(src, { recursive: true, force: true }); + fs.rmSync(path.dirname(out), { recursive: true, force: true }); + fs.rmSync(dst, { recursive: true, force: true }); + } +}); + +test("packing is byte-deterministic across runs", async () => { + const src = tmpDir("s8-det-"); + const outDir = tmpDir("s8-det-out-"); + try { + buildWebTree(src); + const a = path.join(outDir, "a.tar.gz"); + const b = path.join(outDir, "b.tar.gz"); + await runPack({ dir: src, out: a }); + await runPack({ dir: src, out: b }); + assert.equal(sha256File(a), sha256File(b), "two packs of the same tree must be identical"); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + } +}); + +test("restore rejects a corrupted archive before extraction", async () => { + const src = tmpDir("s8-tamper-"); + const outDir = tmpDir("s8-tamper-out-"); + try { + buildWebTree(src); + const out = path.join(outDir, "web-bundle.tar.gz"); + await runPack({ dir: src, out }); + const raw = fs.readFileSync(out); + raw[raw.length - 10] ^= 0xff; // flip one byte in the gzip trailer region + fs.writeFileSync(out, raw); + await assert.rejects(() => runRestore({ archive: out, dir: path.join(outDir, "dst") }), /sha/); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + } +}); + +test("manifest verification flags modified and smuggled files in a restored tree", async () => { + const src = tmpDir("s8-verify-"); + const outDir = tmpDir("s8-verify-out-"); + const dst = tmpDir("s8-verify-dst-"); + try { + buildWebTree(src); + const out = path.join(outDir, "web-bundle.tar.gz"); + await runPack({ dir: src, out }); + await runRestore({ archive: out, dir: dst }); + + fs.appendFileSync(path.join(dst, "standalone", "server.js"), "// tampered\n"); + fs.writeFileSync(path.join(dst, "static", "smuggled.js"), "evil();\n"); + + const manifest = JSON.parse(fs.readFileSync(`${out}.manifest.json`, "utf8")); + const verdict = await verifyStandaloneManifest(dst, manifest); + assert.equal(verdict.ok, false); + assert.ok( + verdict.errors.some((e) => e.includes("standalone/server.js")), + `content tampering detected: ${verdict.errors.join("; ")}` + ); + assert.ok( + verdict.errors.some((e) => e.includes("unlisted files") && e.includes("static/smuggled.js")), + `smuggled file detected: ${verdict.errors.join("; ")}` + ); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + fs.rmSync(outDir, { recursive: true, force: true }); + fs.rmSync(dst, { recursive: true, force: true }); + } +}); + +test("manifest verification rejects an unsupported manifest version", async () => { + const dst = tmpDir("s8-ver-"); + try { + const verdict = await verifyStandaloneManifest(dst, { + version: MANIFEST_VERSION + 1, + entries: [], + }); + assert.equal(verdict.ok, false); + assert.match(verdict.errors[0] ?? "", /unsupported manifest version/); + } finally { + fs.rmSync(dst, { recursive: true, force: true }); + } +}); + +test("hydratePlatformNatives swaps install-machine-forked packages for this leg", () => { + const standalone = tmpDir("s8-hydrate-sa-"); + const source = tmpDir("s8-hydrate-src-"); + try { + // The ubuntu-built standalone carries linux sharp + darwin-only fsevents. + writeNative( + standalone, + "node_modules/@img/sharp-linux-x64/package.json", + '{"name":"@img/sharp-linux-x64"}' + ); + writeNative(standalone, "node_modules/@img/sharp-linux-x64/lib/index.js", "linux fork"); + writeNative(standalone, "node_modules/fsevents/fsevents.js", "mac only"); + // This leg (darwin-arm64) resolved its own forks: different sharp, no fsevents. + writeNative( + source, + "node_modules/@img/sharp-darwin-arm64/package.json", + '{"name":"@img/sharp-darwin-arm64"}' + ); + writeNative(source, "node_modules/@img/sharp-darwin-arm64/lib/index.js", "darwin fork"); + + const result = hydratePlatformNatives({ + standaloneNodeModules: path.join(standalone, "node_modules"), + sourceNodeModules: path.join(source, "node_modules"), + }); + + // Platform forks ship under different package names, so hydration is + // remove(standalone fork) + copy(this leg's fork); `replaced` stays empty + // unless the exact same name exists on both sides. + assert.deepEqual(result.copied.sort(), ["@img/sharp-darwin-arm64"]); + assert.deepEqual(result.replaced, []); + assert.deepEqual(result.removed.sort(), ["@img/sharp-linux-x64", "fsevents"]); + assert.ok( + fs.existsSync( + path.join(standalone, "node_modules", "@img", "sharp-darwin-arm64", "lib", "index.js") + ), + "darwin fork copied in" + ); + assert.ok( + !fs.existsSync(path.join(standalone, "node_modules", "@img", "sharp-linux-x64")), + "linux fork removed" + ); + assert.ok( + !fs.existsSync(path.join(standalone, "node_modules", "fsevents")), + "fsevents dropped on non-matching leg" + ); + } finally { + fs.rmSync(standalone, { recursive: true, force: true }); + fs.rmSync(source, { recursive: true, force: true }); + } +}); + +test("verifyBundledNatives asserts serviceability and honors the onnx darwin-x64 exemption", () => { + const root = tmpDir("s8-natives-"); + try { + const nm = path.join(root, "node_modules"); + writeNative(nm, "koffi/build/koffi/linux_x64/koffi.node", "elf"); + writeNative(nm, "better-sqlite3/prebuilds/linux-x64.node", "napi"); + writeNative(nm, "wreq-js/rust/wreq-js.linux-x64-gnu.node", "rust"); + writeNative(nm, "onnxruntime-node/bin/napi-v6/linux/x64/libonnxruntime.so", "ort"); + + const good = verifyBundledNatives({ nodeModulesDir: nm, platform: "linux", arch: "x64" }); + assert.equal( + good.ok, + true, + `expected serviceable: ${(good as { errors?: string[] }).errors?.join("; ")}` + ); + + const missingKoffi = verifyBundledNatives({ + nodeModulesDir: nm, + platform: "darwin", + arch: "arm64", + }); + assert.equal(missingKoffi.ok, false); + assert.ok((missingKoffi as { errors: string[] }).errors.some((e) => e.startsWith("koffi:"))); + + // darwin-x64 has no onnxruntime-node prebuild at all — the exemption must keep it green + // as long as the other bundled natives service that triple. + const nm2 = path.join(root, "node_modules2"); + writeNative(nm2, "koffi/build/koffi/darwin_x64/koffi.node", "macho"); + writeNative(nm2, "better-sqlite3/prebuilds/darwin-x64.node", "napi"); + writeNative(nm2, "wreq-js/rust/wreq-js.darwin-x64.node", "rust"); + const exempted = verifyBundledNatives({ nodeModulesDir: nm2, platform: "darwin", arch: "x64" }); + assert.equal( + exempted.ok, + true, + `darwin-x64 must pass via exemption: ${(exempted as { errors?: string[] }).errors?.join("; ")}` + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); From 8bd0e7b6bfa197409977bee2fad9145140db5761 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:42:53 -0300 Subject: [PATCH 34/36] deps: bump the production group across 1 directory with 21 updates (#10403) Bumps the production group with 20 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1096.0` | `3.1107.0` | | [@toon-format/toon](https://github.com/toon-format/toon) | `4.1.0` | `4.1.1` | | [axios](https://github.com/axios/axios) | `1.18.1` | `1.19.0` | | [cron-parser](https://github.com/harrisiirak/cron-parser) | `5.7.0` | `5.8.1` | | [csv-stringify](https://github.com/adaltas/node-csv/tree/HEAD/packages/csv-stringify) | `6.8.1` | `6.8.3` | | [fumadocs-core](https://github.com/fuma-nama/fumadocs) | `16.13.0` | `16.14.3` | | [fumadocs-ui](https://github.com/fuma-nama/fumadocs) | `16.13.0` | `16.14.3` | | [jose](https://github.com/panva/jose) | `6.2.4` | `6.2.8` | | [js-yaml](https://github.com/nodeca/js-yaml) | `5.2.2` | `5.2.3` | | [marked](https://github.com/markedjs/marked) | `18.0.7` | `18.0.9` | | [material-symbols](https://github.com/marella/material-symbols/tree/HEAD/material-symbols) | `0.45.9` | `0.45.10` | | [next](https://github.com/vercel/next.js) | `16.2.12` | `16.3.0` | | [next-intl](https://github.com/amannn/next-intl) | `4.13.4` | `4.13.6` | | [playwright](https://github.com/microsoft/playwright) | `1.61.1` | `1.62.1` | | [smol-toml](https://github.com/squirrelchat/smol-toml) | `1.7.1` | `1.7.2` | | [tsx](https://github.com/privatenumber/tsx) | `4.23.1` | `4.23.12` | | [turndown](https://github.com/mixmark-io/turndown) | `7.2.0` | `7.2.4` | | [ws](https://github.com/websockets/ws) | `8.21.1` | `8.21.3` | | [onnxruntime-node](https://github.com/Microsoft/onnxruntime) | `1.24.3` | `1.27.0` | | [wreq-js](https://github.com/sqdshguy/wreq-js) | `2.3.1` | `3.0.0` | Updates `@aws-sdk/client-bedrock-runtime` from 3.1096.0 to 3.1107.0 - [Release notes](https://github.com/aws/aws-sdk-js-v3/releases) - [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1107.0/clients/client-bedrock-runtime) Updates `@toon-format/toon` from 4.1.0 to 4.1.1 - [Release notes](https://github.com/toon-format/toon/releases) - [Commits](https://github.com/toon-format/toon/compare/v4.1.0...v4.1.1) Updates `axios` from 1.18.1 to 1.19.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.18.1...v1.19.0) Updates `cron-parser` from 5.7.0 to 5.8.1 - [Release notes](https://github.com/harrisiirak/cron-parser/releases) - [Changelog](https://github.com/harrisiirak/cron-parser/blob/master/CHANGELOG.md) - [Commits](https://github.com/harrisiirak/cron-parser/compare/v5.7.0...v5.8.1) Updates `csv-stringify` from 6.8.1 to 6.8.3 - [Changelog](https://github.com/adaltas/node-csv/blob/master/packages/csv-stringify/CHANGELOG.md) - [Commits](https://github.com/adaltas/node-csv/commits/csv-stringify@6.8.3/packages/csv-stringify) Updates `fumadocs-core` from 16.13.0 to 16.14.3 - [Release notes](https://github.com/fuma-nama/fumadocs/releases) - [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.13.0...fumadocs@16.14.3) Updates `fumadocs-ui` from 16.13.0 to 16.14.3 - [Release notes](https://github.com/fuma-nama/fumadocs/releases) - [Commits](https://github.com/fuma-nama/fumadocs/compare/fumadocs@16.13.0...fumadocs@16.14.3) Updates `jose` from 6.2.4 to 6.2.8 - [Release notes](https://github.com/panva/jose/releases) - [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md) - [Commits](https://github.com/panva/jose/compare/v6.2.4...v6.2.8) Updates `js-yaml` from 5.2.2 to 5.2.3 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3) Updates `lucide-react` from 1.27.0 to 1.31.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.31.0/packages/lucide-react) Updates `marked` from 18.0.7 to 18.0.9 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v18.0.7...v18.0.9) Updates `material-symbols` from 0.45.9 to 0.45.10 - [Release notes](https://github.com/marella/material-symbols/releases) - [Commits](https://github.com/marella/material-symbols/commits/v0.45.10/material-symbols) Updates `next` from 16.2.12 to 16.3.0 - [Release notes](https://github.com/vercel/next.js/releases) - [Commits](https://github.com/vercel/next.js/compare/v16.2.12...v16.3.0) Updates `next-intl` from 4.13.4 to 4.13.6 - [Release notes](https://github.com/amannn/next-intl/releases) - [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md) - [Commits](https://github.com/amannn/next-intl/compare/v4.13.4...v4.13.6) Updates `playwright` from 1.61.1 to 1.62.1 - [Release notes](https://github.com/microsoft/playwright/releases) - [Commits](https://github.com/microsoft/playwright/compare/v1.61.1...v1.62.1) Updates `smol-toml` from 1.7.1 to 1.7.2 - [Release notes](https://github.com/squirrelchat/smol-toml/releases) - [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.7.1...v1.7.2) Updates `tsx` from 4.23.1 to 4.23.12 - [Release notes](https://github.com/privatenumber/tsx/releases) - [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs) - [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.12) Updates `turndown` from 7.2.0 to 7.2.4 - [Release notes](https://github.com/mixmark-io/turndown/releases) - [Commits](https://github.com/mixmark-io/turndown/compare/v7.2.0...v7.2.4) Updates `ws` from 8.21.1 to 8.21.3 - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.21.1...8.21.3) Updates `onnxruntime-node` from 1.24.3 to 1.27.0 - [Release notes](https://github.com/Microsoft/onnxruntime/releases) - [Changelog](https://github.com/microsoft/onnxruntime/blob/main/docs/ReleaseNotesWorkflow.md) - [Commits](https://github.com/Microsoft/onnxruntime/compare/v1.24.3...v1.27.0) Updates `wreq-js` from 2.3.1 to 3.0.0 - [Release notes](https://github.com/sqdshguy/wreq-js/releases) - [Commits](https://github.com/sqdshguy/wreq-js/compare/v2.3.1...v3.0.0) --- updated-dependencies: - dependency-name: "@aws-sdk/client-bedrock-runtime" dependency-version: 3.1107.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production - dependency-name: "@toon-format/toon" dependency-version: 4.1.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: axios dependency-version: 1.19.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production - dependency-name: cron-parser dependency-version: 5.8.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production - dependency-name: csv-stringify dependency-version: 6.8.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: fumadocs-core dependency-version: 16.14.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production - dependency-name: fumadocs-ui dependency-version: 16.14.3 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production - dependency-name: jose dependency-version: 6.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: js-yaml dependency-version: 5.2.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: lucide-react dependency-version: 1.31.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production - dependency-name: marked dependency-version: 18.0.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: material-symbols dependency-version: 0.45.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: next dependency-version: 16.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production - dependency-name: next-intl dependency-version: 4.13.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: playwright dependency-version: 1.62.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production - dependency-name: smol-toml dependency-version: 1.7.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: tsx dependency-version: 4.23.12 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: turndown dependency-version: 7.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: ws dependency-version: 8.21.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: production - dependency-name: onnxruntime-node dependency-version: 1.27.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: production - dependency-name: wreq-js dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: adevwithpurpose --- package-lock.json | 996 ++++++++++++++++------------- package.json | 40 +- packages/browser-pool/package.json | 2 +- 3 files changed, 567 insertions(+), 471 deletions(-) diff --git a/package-lock.json b/package-lock.json index 91dd584ddf..f08d7a25ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "packages/browser-pool" ], "dependencies": { - "@aws-sdk/client-bedrock-runtime": "^3.1073.0", + "@aws-sdk/client-bedrock-runtime": "^3.1107.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -24,51 +24,51 @@ "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", - "@toon-format/toon": "^4.1.0", + "@toon-format/toon": "^4.1.1", "@types/mdx": "^2.0.13", "@xyflow/react": "^12.11.1", - "axios": "^1.16.1", + "axios": "^1.19.0", "bcryptjs": "^3.0.3", "bottleneck": "^2.19.5", "clsx": "^2.1.1", "commander": "^15.0.0", - "cron-parser": "^5.6.2", - "csv-stringify": "^6.7.0", + "cron-parser": "^5.8.1", + "csv-stringify": "^6.8.3", "dompurify": "^3.4.13", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", - "fumadocs-core": "^16.10.5", - "fumadocs-ui": "^16.10.5", + "fumadocs-core": "^16.14.3", + "fumadocs-ui": "^16.14.3", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", - "jose": "^6.2.3", - "js-yaml": "^5.2.2", + "jose": "^6.2.8", + "js-yaml": "^5.2.3", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.21.0", - "marked": "^18.0.4", + "marked": "^18.0.9", "marked-terminal": "^7.3.0", - "material-symbols": "^0.45.2", + "material-symbols": "^0.45.10", "mermaid": "^11.15.0", "monaco-editor": "^0.56.0", - "next": "16.2.12", - "next-intl": "^4.12.0", + "next": "16.3.0", + "next-intl": "^4.13.6", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.0.2", - "onnxruntime-node": "~1.24.3", + "onnxruntime-node": "~1.27.0", "open": "^11.0.0", "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", - "playwright": "1.62.0", + "playwright": "1.62.1", "react": "19.2.8", "react-dom": "19.2.8", "react-is": "^19.2.6", @@ -78,17 +78,18 @@ "safe-regex": "^2.1.1", "selfsigned": "^5.5.0", "sharp": "^0.35.3", - "smol-toml": "1.7.1", + "smol-toml": "1.7.2", "socks": "^2.8.7", "sql.js": "^1.14.1", "tailwind-merge": "^3.6.0", - "tsx": "^4.23.0", - "turndown": "7.2.0", + "tsx": "^4.23.12", + "turndown": "7.2.4", "turndown-plugin-gfm": "1.0.2", "undici": "^8.10.0", "update-notifier": "^7.3.1", "uuid": "^14.0.0", - "ws": "^8.18.0", + "wreq-js": "3.0.0", + "ws": "^8.21.3", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", "zod": "^4.4.3", @@ -162,7 +163,7 @@ "keytar": "^7.9.0", "sqlite-vec": "^0.1.9", "tls-client-node": "^0.2.0", - "wreq-js": "^2.3.1" + "wreq-js": "^3.0.0" } }, "node_modules/@adobe/css-tools": { @@ -610,21 +611,38 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1096.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1096.0.tgz", - "integrity": "sha512-5aZmG71QnMoQQry/UmT9tM1p/W2Sux34bg3nJPN4GP31Ei321jCgOaEVgCNzaRPzUZ94QuKIA5ND9obTlOw3vw==", + "version": "3.1107.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1107.0.tgz", + "integrity": "sha512-qeaRwHqwPx7OU3d3zuI4Kivtq3vF3WL4w83vuWpZosmbQzgQCka8jsMoHhIVr1ewmuTekYhcPsYY32TqjC6HcA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/credential-provider-node": "^3.972.73", - "@aws-sdk/eventstream-handler-node": "^3.972.30", - "@aws-sdk/middleware-eventstream": "^3.972.25", - "@aws-sdk/middleware-websocket": "^3.972.44", - "@aws-sdk/token-providers": "3.1096.0", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/eventstream-handler-node": "^3.972.31", + "@aws-sdk/middleware-eventstream": "^3.972.26", + "@aws-sdk/middleware-websocket": "^3.972.49", + "@aws-sdk/token-providers": "3.1107.0", "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/node-http-handler": "^4.9.10", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime/node_modules/@aws-sdk/token-providers": { + "version": "3.1107.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1107.0.tgz", + "integrity": "sha512-cZXQRFWBxswmcUOin+ZvzTyGEE1Daj9E2n+1jdBSAsWCD+56jlfSgCd+I2qVE6h3ZJBDNI9aTSwWLX0f4lpLhg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -678,16 +696,16 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.1.tgz", - "integrity": "sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==", + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.37", + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.8", - "@smithy/signature-v4": "^5.6.9", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -697,14 +715,14 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.62", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.62.tgz", - "integrity": "sha512-BkDrk2cNjed31IKin/Oksb2ziF+gfuyRskFVuT4EU9Mep7M8Y/d8DJG4+anHme4Vuse7CwaEscwEfGyR6mzBhQ==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -713,16 +731,16 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.64", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.64.tgz", - "integrity": "sha512-Wj1FGK2IxY5EccQCvH+niTYhIvDoDujJf2CpRRgS3NpYNEgiFNVItNbJYQjINRlu7fG7jSsXkKV0UWKriEplrw==", + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/node-http-handler": "^4.9.10", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -731,22 +749,22 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.7.tgz", - "integrity": "sha512-2CefB8cCxDu52P24B8Ay93/cTT199bcSvNHQ8e2f4BjSCF83yErBnTIZEBo0VeIgCfmw+PJKFUXnlQWxm2dkug==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/credential-provider-env": "^3.972.62", - "@aws-sdk/credential-provider-http": "^3.972.64", - "@aws-sdk/credential-provider-login": "^3.972.69", - "@aws-sdk/credential-provider-process": "^3.972.62", - "@aws-sdk/credential-provider-sso": "^3.973.6", - "@aws-sdk/credential-provider-web-identity": "^3.972.68", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/credential-provider-imds": "^4.4.13", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -755,15 +773,15 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.69", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.69.tgz", - "integrity": "sha512-gM3j0Ie9+FoLNTYODY+QWbg3vCRBc7mR9cRdntxTMkFYIrwfRmuucfavP6HNBlYSuaYww54TNJGej4GFgoPZAg==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -772,20 +790,20 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.73", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.73.tgz", - "integrity": "sha512-VTzdbf8Ukjdb9yUubZzRI678CWZvKovhE8Nv3qihwhC187sRMGls+r9N8Wuht5q1xjKx2nmpS48ar8ppupjkCA==", + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.62", - "@aws-sdk/credential-provider-http": "^3.972.64", - "@aws-sdk/credential-provider-ini": "^3.973.7", - "@aws-sdk/credential-provider-process": "^3.972.62", - "@aws-sdk/credential-provider-sso": "^3.973.6", - "@aws-sdk/credential-provider-web-identity": "^3.972.68", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/credential-provider-imds": "^4.4.13", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -794,14 +812,14 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.62", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.62.tgz", - "integrity": "sha512-zXYU9UWNL66gtMgNLhmxlrvEokuI7r6G2q7FRGu41Bya4iS30JLelUipJX9SV4zhyCPWJhI9Li54R1d9H8Tq6A==", + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -810,16 +828,16 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.6.tgz", - "integrity": "sha512-DobZggy3K49xdCpjeyMou0FQhkoYbluVGNydL6D+lcxF8GoAsttFX0xnH5GmiQ89We5dB6TRpW+CD/VowBH6HQ==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/token-providers": "3.1096.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -828,15 +846,15 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.68.tgz", - "integrity": "sha512-bq+yTt+uWJx60VVp/OIAX5xqUAu/K2Uc3eknWnWl+KtfcU2CQe0uNw6lySrn2t5GKHq7jsV0Z63HiBGVtzr/lg==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -845,13 +863,13 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.30.tgz", - "integrity": "sha512-hJboPgIpq5+ADc++/B9TBqn65CXV21cZLGB8V5RBQbxkZ/rQ6qMfcxTnW/SvQlasX4jhaSG8B1wsVjhQyDrsnQ==", + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.32.tgz", + "integrity": "sha512-rlbmsMG7ZNgrVhWSqqXpq6y9hfiREyzCg3CNTk9UK+AoP7+65kOkqpWmqwLfV1UrRSHATdLnZF2rt9ZTUxYQJA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -860,13 +878,13 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.25.tgz", - "integrity": "sha512-9SFbPzJDHHR5k6Q6KvXVas/veUm/TzNcNTFM2UhdXHZHpyIvI2lS+s4cxljw1BihGpVhsAkQDo/2nW7dHxpf4Q==", + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.27.tgz", + "integrity": "sha512-M7Ay1VpBpf/YFfic9kkjwE3wyCh4G0gEM4RypRXYm7aPjyfqi+D8FEYMR2E3IqbvN+qi2rEFYAiwWL0XHtQYdQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -894,16 +912,16 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "version": "3.972.44", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.44.tgz", - "integrity": "sha512-MPjH/vT1UZc7RSdvP/bIZCJqQCOORei84D6a7dwBuvdwOIskTsQ2EczlTRFQu7yWpGMQr1x3xdpDRHjWlTH2Tw==", + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.50.tgz", + "integrity": "sha512-gdcWRbmIf1dWA/prf44Bnnzgqj+AbsXX2yfhZhOQLwSm7NfKIYPmkRlPqP0CTepHzjxMIBdWBDdtQB+Y/dFUeg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/signature-v4": "^5.6.9", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -912,17 +930,17 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.36", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.36.tgz", - "integrity": "sha512-b71Suv7L+DnhM0MsQHU4WO42I32kxLZi96PbVhZbxMYIoKnEZz3v+LSrG8fupAoA4cBSshCk1Dl/PeRz49qUSg==", + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/signature-v4-multi-region": "^3.996.42", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/node-http-handler": "^4.9.10", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -931,13 +949,13 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz", - "integrity": "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==", + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/signature-v4": "^5.6.9", + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -946,15 +964,15 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1096.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1096.0.tgz", - "integrity": "sha512-hdUS2hDppy3vkWeFl5y86RLNU6OWH2mQB09yOSsRefwhhGTSFPkaZvfLDD/9vFcvMzlr8QFQFw3fw2FtrurVQA==", + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.1", - "@aws-sdk/nested-clients": "^3.997.36", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, @@ -963,9 +981,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", - "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -976,9 +994,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", - "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -3251,9 +3269,9 @@ "license": "MIT" }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "3.5.15", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.15.tgz", - "integrity": "sha512-5o4grXKotAB3JqQuisLApHG43g17N+paoRTa92Jiz35Zvfemq0cVf4EDvuxyHAzmsJji7igaEowicLO/VmfJ8Q==", + "version": "3.5.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.16.tgz", + "integrity": "sha512-kl6b/4D56gjGZi4ZewSmvXbalHwjOUI5ogEHPZqw42goeXTTrL7/yuPzvdrvr0QigDtvaOeb+UeMf62jks43Yg==", "license": "MIT", "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.11" @@ -3488,6 +3506,97 @@ "sharp": "^0.34.5" } }, + "node_modules/@huggingface/transformers/node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/@huggingface/transformers/node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-common": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", + "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "license": "MIT" + }, + "node_modules/@huggingface/transformers/node_modules/onnxruntime-node": { + "version": "1.24.3", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", + "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^3.0.0", + "onnxruntime-common": "1.24.3" + } + }, + "node_modules/@huggingface/transformers/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@huggingface/transformers/node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@huggingface/transformers/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -5358,9 +5467,9 @@ "license": "MIT" }, "node_modules/@next/env": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.12.tgz", - "integrity": "sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -5375,9 +5484,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.12.tgz", - "integrity": "sha512-0W1R0teHWJrqKX0FH20IzzIWAOuGtBxPGuObrxy1lE8hQvCFj49KE8a3WUg0D7sq6rn6zkM4c7YGUnhudBS6oA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", "cpu": [ "arm64" ], @@ -5391,9 +5500,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.12.tgz", - "integrity": "sha512-Hy5Ls099+aFUmOLmIgPfLqNi6iCwhL3uQCssz5rWk+5Nkc6TUKCE83DY5BbNylfm3+mfwcSFnLRfrZDJhVxdtw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", "cpu": [ "x64" ], @@ -5407,12 +5516,15 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.12.tgz", - "integrity": "sha512-+YqU2h1cQkHsGfvjAsrSmst8UIFBibBGm5x3Xgel8NLMiDQtNOM4sM2GOEMvG5YiOBNeN/Ykk8cQC2S0Xrqljg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5423,12 +5535,15 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.12.tgz", - "integrity": "sha512-0qjhiYBaKAqF63LA1ZWAAnKTzFUguAaZiRa5etMLGGPj/B6uEVjtIZldIzFEp3wHlB0koK6aTzqPtSdplTCjoA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5439,12 +5554,15 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.12.tgz", - "integrity": "sha512-7A3q26W+h7gnA15uqBToNuDqBEFZZcqh0mW2mn4AJh/G5pdg2RVE3n4slzLEliASZFG3NmsbEzng/x2Sh09mBg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5455,12 +5573,15 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.12.tgz", - "integrity": "sha512-qSjL/uppm+cbh21s72Ss8gkiOhQ4dExWHNGOWy6eZV7STj5WsKehgxT61beSsOj+YYQuTplL376lOCdMQU5T8w==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5471,9 +5592,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.12.tgz", - "integrity": "sha512-X6hzsOUJac/e7AWSbn9gQ9nzHld1xWP5iyjHpYWvud8pufB679O1xg4JDyKr8Xd69Jvd+kM2Der6uftiZCmjYA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", "cpu": [ "arm64" ], @@ -5487,9 +5608,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.12.tgz", - "integrity": "sha512-F6fakeHuFTLOPt0bslQJdf+xtT+WIP9DVn/m4y1w1mRnVPyh3D/cNvzlRkxM444xfm+IvvYNSOrKiA2CDJ0Uxw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", "cpu": [ "x64" ], @@ -6991,15 +7112,6 @@ "node": ">=14" } }, - "node_modules/@orama/orama": { - "version": "3.1.18", - "resolved": "https://registry.npmjs.org/@orama/orama/-/orama-3.1.18.tgz", - "integrity": "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==", - "license": "Apache-2.0", - "engines": { - "node": ">= 20.0.0" - } - }, "node_modules/@oven/bun-darwin-aarch64": { "version": "1.3.14", "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.14.tgz", @@ -8526,38 +8638,6 @@ "node": ">=20" } }, - "node_modules/@playwright/test/node_modules/playwright": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", - "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.62.1" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=20" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/@playwright/test/node_modules/playwright-core": { - "version": "1.62.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", - "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -9843,15 +9923,15 @@ "license": "MIT" }, "node_modules/@shikijs/core": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.3.1.tgz", - "integrity": "sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", "license": "MIT", "dependencies": { - "@shikijs/primitive": "4.3.1", - "@shikijs/types": "4.3.1", + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", + "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" }, "engines": { @@ -9859,12 +9939,12 @@ } }, "node_modules/@shikijs/engine-javascript": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.3.1.tgz", - "integrity": "sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" }, @@ -9873,12 +9953,12 @@ } }, "node_modules/@shikijs/engine-oniguruma": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.3.1.tgz", - "integrity": "sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2" }, "engines": { @@ -9886,51 +9966,51 @@ } }, "node_modules/@shikijs/langs": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.3.1.tgz", - "integrity": "sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1" + "@shikijs/types": "4.4.3" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/primitive": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.3.1.tgz", - "integrity": "sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/themes": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.3.1.tgz", - "integrity": "sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", "license": "MIT", "dependencies": { - "@shikijs/types": "4.3.1" + "@shikijs/types": "4.4.3" }, "engines": { "node": ">=20" } }, "node_modules/@shikijs/types": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.3.1.tgz", - "integrity": "sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" }, "engines": { "node": ">=20" @@ -10138,12 +10218,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.0.tgz", - "integrity": "sha512-sylYk2l9d7CmRv8ts8p0SDQUr3VO+HMeS1nrjL6+UtbO8ktJHTOeQ1McX+aAyvGGccp5aZX9eNtdcXrSwzoZaw==", + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.0.tgz", + "integrity": "sha512-uKbkxgqLyepQDZoq8aRSdUqD1ID//rOqG96ixBhp++O7vBtmwYM6fwldGhr9HJP0iYrdc7GP/AlgzPWEZIrNRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10151,13 +10231,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.15", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.15.tgz", - "integrity": "sha512-xYVGrisQqTJWhOnScUhbx8s9H63TMtoxzuUoxG6mP8J+B/YbX3vZxVsgV0xDf43abJnJP0fjP7BkQh7OESwuRA==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10165,13 +10245,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.12", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.12.tgz", - "integrity": "sha512-OpQgP6IGH4j0NJ2zjfYZLjQL85ai+Wi/q51EmZJovXsEwKSvu89qiXUq77Q6EmwZ/hSl7fKpn2Z9mhiDN6OM+Q==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10179,13 +10259,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.9.12", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.12.tgz", - "integrity": "sha512-dWW5KRt4mnEvjNzbGqGeCuAvgum85Y9ZoyuMQqcTEfapndyVJ1k9BEHK7kdXJZ32enyRmmwcFjMwlB/KgLKI3Q==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.0.tgz", + "integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10193,13 +10273,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.11", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.11.tgz", - "integrity": "sha512-7HsspeiNCZvZHEJ22vV5L/QYuJdTyJvPJvMrYD3AgkM3IJB0pkln4jkjPvtpTWRMkHXbO8WKwNjoVdVlBFwHmw==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.31.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", "tslib": "^2.6.2" }, "engines": { @@ -10207,9 +10287,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", - "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -11483,9 +11563,9 @@ "optional": true }, "node_modules/@toon-format/toon": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.0.tgz", - "integrity": "sha512-dBB3pkEx9QYvHnHR6rtkaBAh+7x4W/oA5ONur4G0fh7Ow69PbPuM7OFxzNRABqyxC0t6SZ3RixiGbCuaFjPDAQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-4.1.1.tgz", + "integrity": "sha512-SGCkS7IjVpwRmGPgnY8ENKpAf0EdAnZDOQkvFW0d2cgOpdn9FEFl7sTgryESyypXrWr0YajHGpwsAUX4zw9ZvA==", "license": "MIT" }, "node_modules/@tufjs/canonical-json": { @@ -11884,9 +11964,9 @@ "license": "MIT" }, "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "license": "MIT", "dependencies": { "@types/unist": "*" @@ -13896,13 +13976,13 @@ } }, "node_modules/axios": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", - "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -15611,9 +15691,9 @@ } }, "node_modules/cnfast": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/cnfast/-/cnfast-0.0.8.tgz", - "integrity": "sha512-EjXKMfGfdwtV4AcNSQ6AwQaVzpC1B7IxeiwA3FlhTXz+YFlMKVi4c1JX9tgD2QOlahQXjB8KUXrBaYG+3v871Q==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cnfast/-/cnfast-0.1.0.tgz", + "integrity": "sha512-rH0jBKeLkVrK7NsZ5Ba2l7WdMBmm1k0FMpABeXUU1PgTUxbz3261gEuCSsrYuXo4BwAe3yCcIbQ93YyS52lOGQ==", "license": "MIT", "bin": { "cnfast": "bin/cli.js" @@ -16180,9 +16260,9 @@ } }, "node_modules/cron-parser": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.7.0.tgz", - "integrity": "sha512-iSpDHpwwW/GhIg4JVODYlWUEpMNSimaHvqOhHpOz1W+Y97z1lL1nf+dpcF17cNwFRpTtKN9devgi1fxflp3Phw==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.8.1.tgz", + "integrity": "sha512-fVw5nGEkTVmiPKo3fY0j28Thq6jR00VKWyL22llWrsbII4sDHI+8Kx1kcL+QzGQJfCfk64bbMotrgTZRpzYpLQ==", "license": "MIT", "dependencies": { "luxon": "^3.7.2" @@ -16282,9 +16362,9 @@ "license": "MIT" }, "node_modules/csv-stringify": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.1.tgz", - "integrity": "sha512-tZ6X6TKQyQgCo5OptXcyAbfN1pwmoxEqELPQ7KFazNErx7kiVsDK8o+VYRXhfMl4N9vvOOLXuioquR2MeP847A==", + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.8.3.tgz", + "integrity": "sha512-gIeSCvq5F4VtXV3naV3VAewLhBkiZBz+PPhTOA8H3Y8h/ELa+R1ml0GZck/4/Nzo9ep2lvOluilJ6MJlbZsKMA==", "license": "MIT" }, "node_modules/ctrf": { @@ -19811,12 +19891,12 @@ } }, "node_modules/framer-motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz", - "integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.42.2", + "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, @@ -19896,29 +19976,29 @@ } }, "node_modules/fumadocs-core": { - "version": "16.13.0", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.13.0.tgz", - "integrity": "sha512-J+XhngvMn+tKCrk3MyZzE0xMECCJUjSfRtGTKKP4lP8Py8lXGhgnRMuc+yUip2eCdUIs2+maYyeYEgAFIGHMtA==", + "version": "16.14.3", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.14.3.tgz", + "integrity": "sha512-xoGy6YelmU8GD4RKUiSuraFnRW91DqBM328Gs/YasltLrnMDWgEaYMKAcrGLVrikpqJkFx+etHo8BcClOMNt+A==", "license": "MIT", "dependencies": { - "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", - "npm-to-yarn": "3.1.0", + "npm-to-yarn": "3.2.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.3.1", + "shiki": "^4.4.1", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "zbsearch": "^3.3.4" }, "peerDependencies": { "@mdx-js/mdx": "*", @@ -20084,38 +20164,38 @@ } }, "node_modules/fumadocs-ui": { - "version": "16.13.0", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.13.0.tgz", - "integrity": "sha512-kaULXwY9W0MYEKzFCeDjCX9XW3ABDmsabdYWAFPp2jncH9BO+9xgI/t8OWkTIVngyT5PAMvTJphjMxCSTeIVRQ==", + "version": "16.14.3", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.14.3.tgz", + "integrity": "sha512-ASL9BgFxSe6VrbQ60nxVfpnKBPboeADp330JQvyEAqR1U8uw0T1+Vko8ySXRAWbivdZfYK5fZbCh78EOAMTEqw==", "license": "MIT", "dependencies": { "@fuma-translate/react": "^1.0.2", "@fumadocs/tailwind": "0.1.1", - "@radix-ui/react-accordion": "^1.2.17", - "@radix-ui/react-collapsible": "^1.1.17", - "@radix-ui/react-dialog": "^1.1.20", - "@radix-ui/react-direction": "^1.1.2", - "@radix-ui/react-navigation-menu": "^1.2.19", - "@radix-ui/react-popover": "^1.1.20", - "@radix-ui/react-presence": "^1.1.8", - "@radix-ui/react-scroll-area": "^1.2.15", - "@radix-ui/react-slot": "^1.3.0", - "@radix-ui/react-tabs": "^1.1.18", + "@radix-ui/react-accordion": "^1.2.20", + "@radix-ui/react-collapsible": "^1.1.20", + "@radix-ui/react-dialog": "^1.1.23", + "@radix-ui/react-direction": "^1.1.4", + "@radix-ui/react-navigation-menu": "^1.2.22", + "@radix-ui/react-popover": "^1.1.23", + "@radix-ui/react-presence": "^1.1.10", + "@radix-ui/react-scroll-area": "^1.2.18", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-tabs": "^1.1.21", "class-variance-authority": "^0.7.1", - "cnfast": "^0.0.8", - "lucide-react": "^1.25.0", - "motion": "^12.42.2", + "cnfast": "^0.1.0", + "lucide-react": "^1.28.0", + "motion": "^12.43.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.3.1", + "shiki": "^4.4.1", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.13.0", + "fumadocs-core": "16.14.3", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -20477,17 +20557,15 @@ } }, "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", "license": "BSD-3-Clause", "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" }, "engines": { "node": ">=10.0" @@ -21900,9 +21978,9 @@ } }, "node_modules/icu-minify": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.4.tgz", - "integrity": "sha512-yK6HyPLGlQjqm8fTKtnBpM77z7vl7JdDBN2EXLvmgAu/b7XaOHWZb73M3ISl9ahBTehBv7RYeqqWSHfk1v2YcA==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.13.6.tgz", + "integrity": "sha512-iYZGCJZ+kX6o7GrxpVe2sOSdW86AvEqh8RQBvWeBd9jqmuABsMc2B6xongACfItLOogyIWH6GuBslNHr79OU8Q==", "funding": [ { "type": "individual", @@ -22793,13 +22871,13 @@ } }, "node_modules/intl-messageformat": { - "version": "11.2.12", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.12.tgz", - "integrity": "sha512-KW70Xxfcvy7vV3qODfvShWkFDPMqKDAa4N+hSyVBWGNtVhTUFYaqlD/l88DaYPKiVcPP4rPQ3qnH7i5K82Mg7g==", + "version": "11.2.13", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.13.tgz", + "integrity": "sha512-JaPaE6TIX+TAS5XLhDUh41geLw4QfBHX4s5pW8Km+L9fVC8HzB9yOuhbh4EMR/F1+8C6b9qk4763Cv+LdOG1kg==", "license": "BSD-3-Clause", "dependencies": { "@formatjs/fast-memoize": "3.1.7", - "@formatjs/icu-messageformat-parser": "3.5.15" + "@formatjs/icu-messageformat-parser": "3.5.16" } }, "node_modules/intl-messageformat/node_modules/@formatjs/fast-memoize": { @@ -23743,9 +23821,9 @@ } }, "node_modules/jose": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.4.tgz", - "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -23819,9 +23897,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.2.tgz", - "integrity": "sha512-dayzUzKkJ1MkuUtZglSebU43utNXH0OWQByK9rKOOuYIO8M5TV1y+n8ALMdG0rdzBnfNkOmZEqrURepb0ejqBw==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", + "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", "funding": [ { "type": "github", @@ -25781,9 +25859,9 @@ } }, "node_modules/lucide-react": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.27.0.tgz", - "integrity": "sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.31.0.tgz", + "integrity": "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -25885,9 +25963,9 @@ } }, "node_modules/marked": { - "version": "18.0.7", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.7.tgz", - "integrity": "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA==", + "version": "18.0.9", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.9.tgz", + "integrity": "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -25942,21 +26020,24 @@ } }, "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", "license": "MIT", "dependencies": { "escape-string-regexp": "^4.0.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/material-symbols": { - "version": "0.45.9", - "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.9.tgz", - "integrity": "sha512-CuNJwHm/c13L2NDGvap4k90iFBZFoMQrjBU+GHO/9bh9sUOe5tm3WHFPLa3BEWukMw6dAxwu5PvtlfWh3NppYA==", + "version": "0.45.10", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.10.tgz", + "integrity": "sha512-2A2mgdfZO4es9DFpIOSMAx3d/7erCLQWRm7zGxA3iXt9jV8e2vNLOszbG3kxzYQ6qp5GAWXVl13de8lS2oHVng==", "license": "Apache-2.0" }, "node_modules/math-intrinsics": { @@ -27579,12 +27660,12 @@ "optional": true }, "node_modules/motion": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.42.2.tgz", - "integrity": "sha512-Atvv11yUKIid41cVrRBDVX5m8tF8kNpExRSlbpt6APClhDjtwQssgFHhQzejxw7/7YYbjHSPKBVbHo05BuJT5Q==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz", + "integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==", "license": "MIT", "dependencies": { - "framer-motion": "^12.42.2", + "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -27605,9 +27686,9 @@ } }, "node_modules/motion-dom": { - "version": "12.42.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz", - "integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -28024,16 +28105,16 @@ } }, "node_modules/next": { - "version": "16.2.12", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.12.tgz", - "integrity": "sha512-iD59eYQWmbFcEbX7v/acG5DRym9iw1DdaPoD0WTA920naWsE25wShzJW4+UvAs8MK9EC2kBfIH6vtto1H1PHGw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", "license": "MIT", "dependencies": { - "@next/env": "16.2.12", + "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -28043,15 +28124,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.12", - "@next/swc-darwin-x64": "16.2.12", - "@next/swc-linux-arm64-gnu": "16.2.12", - "@next/swc-linux-arm64-musl": "16.2.12", - "@next/swc-linux-x64-gnu": "16.2.12", - "@next/swc-linux-x64-musl": "16.2.12", - "@next/swc-win32-arm64-msvc": "16.2.12", - "@next/swc-win32-x64-msvc": "16.2.12", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.3.0", + "@next/swc-darwin-x64": "16.3.0", + "@next/swc-linux-arm64-gnu": "16.3.0", + "@next/swc-linux-arm64-musl": "16.3.0", + "@next/swc-linux-x64-gnu": "16.3.0", + "@next/swc-linux-x64-musl": "16.3.0", + "@next/swc-win32-arm64-msvc": "16.3.0", + "@next/swc-win32-x64-msvc": "16.3.0", + "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -28077,9 +28158,9 @@ } }, "node_modules/next-intl": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.4.tgz", - "integrity": "sha512-jhPAT0u0lahIK6E4gVdZAehugWCosBhLG8sV7xMzgSVoJpxHObP+Fiu+z2FfkEW0XPPtr7uEXoUlLEfhxhNMTg==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.13.6.tgz", + "integrity": "sha512-loS6tjWWkr/IP+EV1yXUm9URB54QmZOp4+ZsMZNmeYxY8IZxLvO2esUegnXIDxj5DpK/4BsxwDGfGhlqodpkCQ==", "funding": [ { "type": "individual", @@ -28091,11 +28172,11 @@ "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", - "icu-minify": "^4.13.4", + "icu-minify": "^4.13.6", "negotiator": "^1.0.0", - "next-intl-swc-plugin-extractor": "^4.13.4", + "next-intl-swc-plugin-extractor": "^4.13.6", "po-parser": "^2.1.1", - "use-intl": "^4.13.4" + "use-intl": "^4.13.6" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", @@ -28108,9 +28189,9 @@ } }, "node_modules/next-intl-swc-plugin-extractor": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.4.tgz", - "integrity": "sha512-uN1+NMUYbG6YkO3q+rjc2bvAPX9nQ23owemvHJAyW0pRbQjVDwvNhmrV5qaak0oQc/9okbK17KLT49AoMGhVEQ==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.13.6.tgz", + "integrity": "sha512-M2L8jtPEAXj0CPmXbiW66THdr3OnDqA9IsU1hqv3CdxtVow3Bl9eXPdT9Opeji7L4AFrUZ016dJOs+CoTw66OA==", "license": "MIT" }, "node_modules/next-themes": { @@ -28683,9 +28764,9 @@ } }, "node_modules/npm-to-yarn": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/npm-to-yarn/-/npm-to-yarn-3.1.0.tgz", - "integrity": "sha512-9gNsO/JB3LeWOZXBX09cKMsCPwVcu1ExIf+GUuTN9G+0zZvLIK0nU9+lE9jue3MSKAxPdrh0rO072mWNvciqeQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/npm-to-yarn/-/npm-to-yarn-3.2.0.tgz", + "integrity": "sha512-K1HmQeZT2HrjpsR6KgqbN2FAXL2NrJJmNUSD9ck7HGTVu1JKXox8n9SB+tjbU8m8JGLF4OscrroPepew/L7/Xw==", "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -28980,15 +29061,15 @@ } }, "node_modules/onnxruntime-common": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", - "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.27.0.tgz", + "integrity": "sha512-3KxL5wIVqa8Ex08jxSzncm9CMgw8CjOFyOQ7SxvG9o0cVLlhTNKXyIQuTbtX4tGPJEf73OER2xrjt4HJSBL4ow==", "license": "MIT" }, "node_modules/onnxruntime-node": { - "version": "1.24.3", - "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.24.3.tgz", - "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.27.0.tgz", + "integrity": "sha512-QEzGwrvNBgv4uPVdnbHsOGG4G6T96mdlcFI8aAKPjMU8wOPpVocPXb6k3QGkaZagVTv2G9Bnnbo6Z3JdXr1fQw==", "hasInstallScript": true, "license": "MIT", "os": [ @@ -28998,8 +29079,8 @@ ], "dependencies": { "adm-zip": "^0.5.16", - "global-agent": "^3.0.0", - "onnxruntime-common": "1.24.3" + "global-agent": "^4.1.3", + "onnxruntime-common": "1.27.0" } }, "node_modules/onnxruntime-web": { @@ -30270,12 +30351,12 @@ "license": "MIT" }, "node_modules/playwright": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", - "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.62.0" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" @@ -30292,7 +30373,6 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "license": "Apache-2.0", - "optional": true, "bin": { "playwright-core": "cli.js" }, @@ -30337,9 +30417,9 @@ } }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.62.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", - "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -32931,12 +33011,12 @@ } }, "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", "license": "MIT", "dependencies": { - "type-fest": "^0.13.1" + "type-fest": "^0.20.2" }, "engines": { "node": ">=10" @@ -32946,9 +33026,9 @@ } }, "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" @@ -33127,19 +33207,19 @@ } }, "node_modules/shiki": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.3.1.tgz", - "integrity": "sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", "license": "MIT", "dependencies": { - "@shikijs/core": "4.3.1", - "@shikijs/engine-javascript": "4.3.1", - "@shikijs/engine-oniguruma": "4.3.1", - "@shikijs/langs": "4.3.1", - "@shikijs/themes": "4.3.1", - "@shikijs/types": "4.3.1", + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" + "@types/hast": "^3.0.5" }, "engines": { "node": ">=20" @@ -33381,9 +33461,9 @@ } }, "node_modules/smol-toml": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz", - "integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.2.tgz", + "integrity": "sha512-pXFZ9B2WinEPzxWkMmlYE/oYx2BP+qLrE95wP8tCuK901uLSMGdCb6QSr82z+wnhXkG4+cO+OMLbZB2Cn+97zw==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -34996,9 +35076,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -35084,12 +35164,16 @@ } }, "node_modules/turndown": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", - "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", "license": "MIT", "dependencies": { "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" } }, "node_modules/turndown-plugin-gfm": { @@ -35799,9 +35883,9 @@ } }, "node_modules/use-intl": { - "version": "4.13.4", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.4.tgz", - "integrity": "sha512-wRhU5zyPNgu845++EJ8ckQsi89b22QUop7NlGxNXpsnKSwEJr7WErAkdAYeVQgFTmDWsa8e2NI1e14XbWz9Ecw==", + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.13.6.tgz", + "integrity": "sha512-RLej84qL6PGTDp/PSG3tqRpwr7IvJfOu4Qfv/uyy8CrnYn1oEOQb6osNJb++jZ7FQxqN3aQ5BI7wTIerUgrgMA==", "funding": [ { "type": "individual", @@ -35812,7 +35896,7 @@ "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", - "icu-minify": "^4.13.4", + "icu-minify": "^4.13.6", "intl-messageformat": "^11.1.0" }, "peerDependencies": { @@ -36739,9 +36823,9 @@ "license": "ISC" }, "node_modules/wreq-js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/wreq-js/-/wreq-js-2.3.1.tgz", - "integrity": "sha512-vaKasaKeskrDKEuuO5Q5uamEG9a6FrF5ZSicH7TCvYS4RxF7/gzaU/vYqwJzcs+uydyJPVWY1KCvfVCgp0tiGA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/wreq-js/-/wreq-js-3.0.0.tgz", + "integrity": "sha512-RZCoRSevVPpH4A4B4MxbFGo/pVPFveWd2gbe4ENKpPWlKXEYklZSDESOjBMmrIsmnkHh+nhM4PNJvG+NL7wBPA==", "cpu": [ "x64", "arm64" @@ -36752,7 +36836,10 @@ "darwin", "linux", "win32" - ] + ], + "engines": { + "node": ">=20.0.0" + } }, "node_modules/write-file-atomic": { "version": "7.0.1", @@ -36768,9 +36855,9 @@ } }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -37178,6 +37265,15 @@ "@yuku-toolchain/types": "^0.8.4" } }, + "node_modules/zbsearch": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/zbsearch/-/zbsearch-3.3.4.tgz", + "integrity": "sha512-xGsv9rIwrili/fpLpVwmnCovEcvaAJg1ey+3Ur0+m3x1mnGoVO71iAwn4op420QLGNsQJNmScZjFq5TQ+cRi/g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20.0.0" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -37256,7 +37352,7 @@ "name": "@omniroute/browser-pool", "version": "0.1.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.1" }, "devDependencies": { "@types/node": "^22" diff --git a/package.json b/package.json index 51a6e5791d..c4c527f79a 100644 --- a/package.json +++ b/package.json @@ -257,7 +257,7 @@ "alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs" }, "dependencies": { - "@aws-sdk/client-bedrock-runtime": "^3.1073.0", + "@aws-sdk/client-bedrock-runtime": "^3.1107.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -266,40 +266,40 @@ "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", - "@toon-format/toon": "^4.1.0", + "@toon-format/toon": "^4.1.1", "@types/mdx": "^2.0.13", "@xyflow/react": "^12.11.1", - "axios": "^1.16.1", + "axios": "^1.19.0", "bcryptjs": "^3.0.3", "bottleneck": "^2.19.5", "clsx": "^2.1.1", "commander": "^15.0.0", - "cron-parser": "^5.6.2", - "csv-stringify": "^6.7.0", + "cron-parser": "^5.8.1", + "csv-stringify": "^6.8.3", "dompurify": "^3.4.13", "express": "^5.2.1", "fetch-socks": "^1.3.3", "fflate": "^0.8.3", - "fumadocs-core": "^16.10.5", - "fumadocs-ui": "^16.10.5", + "fumadocs-core": "^16.14.3", + "fumadocs-ui": "^16.14.3", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", "ink-spinner": "^5.0.0", "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", - "jose": "^6.2.3", - "js-yaml": "^5.2.2", + "jose": "^6.2.8", + "js-yaml": "^5.2.3", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.21.0", - "marked": "^18.0.4", + "marked": "^18.0.9", "marked-terminal": "^7.3.0", - "material-symbols": "^0.45.2", + "material-symbols": "^0.45.10", "mermaid": "^11.15.0", "monaco-editor": "^0.56.0", - "next": "16.2.12", - "next-intl": "^4.12.0", + "next": "16.3.0", + "next-intl": "^4.13.6", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.0.2", @@ -309,7 +309,7 @@ "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", - "playwright": "1.62.0", + "playwright": "1.62.1", "react": "19.2.8", "react-dom": "19.2.8", "react-is": "^19.2.6", @@ -319,23 +319,23 @@ "safe-regex": "^2.1.1", "selfsigned": "^5.5.0", "sharp": "^0.35.3", - "smol-toml": "1.7.1", + "smol-toml": "1.7.2", "socks": "^2.8.7", "sql.js": "^1.14.1", "tailwind-merge": "^3.6.0", - "tsx": "^4.23.0", - "turndown": "7.2.0", + "tsx": "^4.23.12", + "turndown": "7.2.4", "turndown-plugin-gfm": "1.0.2", "undici": "^8.10.0", "update-notifier": "^7.3.1", "uuid": "^14.0.0", - "ws": "^8.18.0", + "ws": "^8.21.3", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", "zod": "^4.4.3", "zustand": "^5.0.13", "@huggingface/transformers": "^4.2.0", - "onnxruntime-node": "~1.24.3" + "onnxruntime-node": "~1.27.0" }, "optionalDependencies": { "@atjsh/llmlingua-2": "2.0.3", @@ -344,7 +344,7 @@ "js-tiktoken": "^1.0.20", "keytar": "^7.9.0", "tls-client-node": "^0.2.0", - "wreq-js": "^2.3.1", + "wreq-js": "^3.0.0", "sqlite-vec": "^0.1.9" }, "devDependencies": { diff --git a/packages/browser-pool/package.json b/packages/browser-pool/package.json index 6e773eb7ab..7d0355f1b4 100644 --- a/packages/browser-pool/package.json +++ b/packages/browser-pool/package.json @@ -7,7 +7,7 @@ "main": "./src/index.ts", "types": "./src/index.ts", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.1" }, "devDependencies": { "@types/node": "^22" From e5e13586931268a5ef21f2ad655f93fb619616a6 Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Sun, 16 Aug 2026 05:42:59 +0200 Subject: [PATCH 35/36] fix(antigravity): discover live chat models dynamically (#10422) * fix(antigravity): discover Gemini 3.7 Flash models * fix(antigravity): discover live chat models dynamically * fix(antigravity): keep provider limits sanitizer strict --- open-sse/config/antigravityModelAliases.ts | 63 ++++++++++++++++++- open-sse/services/usage/antigravity.ts | 21 +++---- .../[id]/models/discovery/normalizers.ts | 4 +- src/shared/constants/modelSpecs.ts | 4 +- tests/unit/antigravity-model-aliases.test.ts | 15 +++++ .../antigravity-retired-public-models.test.ts | 20 +++--- .../unit/model-capabilities-registry.test.ts | 4 +- tests/unit/provider-models-route.test.ts | 6 ++ tests/unit/t28-model-catalog-updates.test.ts | 2 + 9 files changed, 114 insertions(+), 25 deletions(-) diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 61aac462a8..80e066592d 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -1,6 +1,26 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ - // Gemini 3.6 Flash tiers returned by the live model selector for both the IDE 2.1.1 - // and CLI 1.1.x client identities. High is the current defaultAgentModelId. + // Gemini 3.7 Flash tiers listed by the current official Antigravity model catalog + // alongside the existing Gemini 3.6 tiers. Keep the upstream model ids unchanged so + // discovery and execution address the same models selected by the native client. + { + id: "gemini-3.7-flash-high", + name: "Gemini 3.7 Flash (High)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + { + id: "gemini-3.7-flash-medium", + name: "Gemini 3.7 Flash (Medium)", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, + // Gemini 3.6 Flash tiers retained alongside the newer Gemini 3.7 tiers. { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", @@ -195,6 +215,32 @@ const UPSTREAM_PUBLIC_MODEL_IDS = new Set( ANTIGRAVITY_PUBLIC_MODELS.map((model) => resolveAntigravityModelId(model.id)) ); +// The authenticated Antigravity `:fetchAvailableModels` response is the source of truth for +// the models enabled for the current account and client version. Keep only known non-chat +// surfaces out of that live catalog; do not require every newly launched chat model to be +// added to this static fallback catalog first. +const ANTIGRAVITY_NON_CHAT_MODEL_IDS = new Set([ + "gemini-3-pro-image-preview", + "gemini-3.1-flash-image", + "gemini-3.1-flash-tts-preview", + "gemini-2.5-flash-preview-tts", + "tab_flash_lite_preview", + "tab_jump_flash_lite_preview", +]); + +const ANTIGRAVITY_RETIRED_MODEL_IDS = new Set([ + "gemini-3-pro-preview", + "gemini-3.1-pro", + "gemini-3.5-flash-high", + "gemini-3.5-flash-medium", + "gemini-3.5-flash-preview", + "gemini-2.5-pro", + "gemini-2.5-computer-use-preview-10-2025", +]); + +const ANTIGRAVITY_NON_CHAT_MODEL_PATTERN = + /(?:^|[-_])(image|imagen|audio|tts|embedding|embed|video|veo)(?:[-_]|$)/i; + export function resolveAntigravityModelId(modelId: string): string { if (!modelId) return modelId; return (ANTIGRAVITY_MODEL_ALIASES as AntigravityModelAliasMap)[modelId] || modelId; @@ -234,3 +280,16 @@ export function isUserCallableAntigravityModelId(modelId: string): boolean { const upstreamId = resolveAntigravityModelId(modelId); return PUBLIC_MODEL_IDS.has(clientId) || UPSTREAM_PUBLIC_MODEL_IDS.has(upstreamId); } + +/** + * Return whether a model reported by Antigravity's authenticated live catalog is eligible for + * chat discovery. The upstream response already applies account/subscription gating and marks + * internal entries with `isInternal`; this predicate only excludes known non-chat surfaces. + */ +export function isDiscoverableAntigravityModelId(modelId: string): boolean { + const id = modelId.trim(); + if (!id || ANTIGRAVITY_NON_CHAT_MODEL_IDS.has(id) || ANTIGRAVITY_RETIRED_MODEL_IDS.has(id)) { + return false; + } + return !ANTIGRAVITY_NON_CHAT_MODEL_PATTERN.test(id); +} diff --git a/open-sse/services/usage/antigravity.ts b/open-sse/services/usage/antigravity.ts index 693771d681..66ceab9311 100644 --- a/open-sse/services/usage/antigravity.ts +++ b/open-sse/services/usage/antigravity.ts @@ -17,7 +17,7 @@ import { getAntigravityFetchAvailableModelsUrls, } from "../../config/antigravityUpstream.ts"; import { - isUserCallableAntigravityModelId, + isDiscoverableAntigravityModelId, toClientAntigravityQuotaModelId, } from "../../config/antigravityModelAliases.ts"; import { isUserCallableAgyModelId } from "../../config/agyModels.ts"; @@ -273,15 +273,12 @@ async function fetchAntigravityUserQuotaCached( const promise = (async () => { try { for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { - const response = await fetch( - `${baseUrl}/v1internal:retrieveUserQuota`, - { - method: "POST", - headers: getAntigravityContentHeaders(clientProfile, accessToken), - body: JSON.stringify({ project: projectId }), - signal: AbortSignal.timeout(10000), - } - ); + const response = await fetch(`${baseUrl}/v1internal:retrieveUserQuota`, { + method: "POST", + headers: getAntigravityContentHeaders(clientProfile, accessToken), + body: JSON.stringify({ project: projectId }), + signal: AbortSignal.timeout(10000), + }); if (!response.ok) continue; @@ -649,7 +646,7 @@ export async function getAntigravityUsage( info.isInternal === true || !(provider === "agy" ? isUserCallableAgyModelId(modelKey) - : isUserCallableAntigravityModelId(modelKey)) || + : isDiscoverableAntigravityModelId(modelKey)) || Object.keys(quotaInfo).length === 0 ) { continue; @@ -702,7 +699,7 @@ export async function getAntigravityUsage( quotas[modelKey] || !(provider === "agy" ? isUserCallableAgyModelId(modelKey) - : isUserCallableAntigravityModelId(modelKey)) + : isDiscoverableAntigravityModelId(modelKey)) ) { continue; } diff --git a/src/app/api/providers/[id]/models/discovery/normalizers.ts b/src/app/api/providers/[id]/models/discovery/normalizers.ts index 5b553fb3fa..50e5d3dcb6 100644 --- a/src/app/api/providers/[id]/models/discovery/normalizers.ts +++ b/src/app/api/providers/[id]/models/discovery/normalizers.ts @@ -8,7 +8,7 @@ import { getAntigravityContentHeaders } from "@omniroute/open-sse/services/antig import { resolveAntigravityClientVersion } from "@omniroute/open-sse/services/antigravityClientProfile.ts"; import { getClientVisibleAntigravityModelName, - isUserCallableAntigravityModelId, + isDiscoverableAntigravityModelId, toClientAntigravityModelId, } from "@omniroute/open-sse/config/antigravityModelAliases.ts"; import { @@ -81,7 +81,7 @@ export function filterUserCallableAntigravityModels( model.isInternal !== true && (provider === "agy" ? isDiscoverableAgyModelId(model.id) - : isUserCallableAntigravityModelId(model.id)) + : isDiscoverableAntigravityModelId(model.id)) ); } diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index ebf1f2ca1d..aef1437926 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -174,10 +174,12 @@ export const MODEL_SPECS: Record = { thinkingBudgetCap: 0, }, - // ── Gemini 3.6 Flash (Antigravity live tiers) ─────────────────── + // ── Gemini 3.7 / 3.6 Flash (Antigravity live tiers) ───────────── // The model id itself selects the upstream 10k/4k/1k reasoning tier. Antigravity // still rejects client-supplied thinking parameters, so keep the explicit-parameter // capability aligned with the existing Gemini 3.5 tier ids. + "gemini-3.7-flash-high": { ...GEMINI_35_FLASH_MODEL_SPEC }, + "gemini-3.7-flash-medium": { ...GEMINI_35_FLASH_MODEL_SPEC }, "gemini-3.6-flash-high": { ...GEMINI_35_FLASH_MODEL_SPEC }, "gemini-3.6-flash-medium": { ...GEMINI_35_FLASH_MODEL_SPEC }, "gemini-3.6-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC }, diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index 18b84a97a0..f1e5cda7f2 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { ANTIGRAVITY_PUBLIC_MODELS, getClientVisibleAntigravityModelName, + isDiscoverableAntigravityModelId, isUserCallableAntigravityModelId, resolveAntigravityModelId, toClientAntigravityModelId, @@ -17,6 +18,8 @@ function getPublicModel(id: string) { } const EXPECTED_FLASH_TIERS = [ + ["gemini-3.7-flash-high", "Gemini 3.7 Flash (High)"], + ["gemini-3.7-flash-medium", "Gemini 3.7 Flash (Medium)"], ["gemini-3.6-flash-low", "Gemini 3.6 Flash (Low)"], ["gemini-3.6-flash-medium", "Gemini 3.6 Flash (Medium)"], ["gemini-3.6-flash-high", "Gemini 3.6 Flash (High)"], @@ -96,6 +99,18 @@ test("isUserCallableAntigravityModelId only allows public chat-capable model IDs assert.equal(isUserCallableAntigravityModelId("unknown-model"), false); }); +test("isDiscoverableAntigravityModelId accepts new live chat models without a static catalog entry", () => { + assert.equal(isDiscoverableAntigravityModelId("gemini-3.8-flash-high"), true); + assert.equal(isDiscoverableAntigravityModelId("claude-sonnet-5"), true); + assert.equal(isDiscoverableAntigravityModelId("gemini-new-live-tier"), true); + + assert.equal(isDiscoverableAntigravityModelId("tab_flash_lite_preview"), false); + assert.equal(isDiscoverableAntigravityModelId("gemini-3.1-flash-image"), false); + assert.equal(isDiscoverableAntigravityModelId("gemini-3.1-flash-tts-preview"), false); + assert.equal(isDiscoverableAntigravityModelId("gemini-2.5-flash-preview-tts"), false); + assert.equal(isDiscoverableAntigravityModelId(""), false); +}); + test("ANTIGRAVITY_PUBLIC_MODELS exposes current live names and capabilities", () => { // #3184: Claude is exposed in the antigravity catalog (same backend as `agy`, verified). // #7129: Opus 4.6, Sonnet 4.6, and Sonnet 5 graduated to a 1M-token context window at GA diff --git a/tests/unit/antigravity-retired-public-models.test.ts b/tests/unit/antigravity-retired-public-models.test.ts index bddb7b7e75..207e6aca43 100644 --- a/tests/unit/antigravity-retired-public-models.test.ts +++ b/tests/unit/antigravity-retired-public-models.test.ts @@ -34,6 +34,12 @@ const EXPECTED_LEADING_MODEL_ORDER = [ "gemini-3.5-flash-extra-low", ] as const; +const EXPECTED_ANTIGRAVITY_LEADING_MODEL_ORDER = [ + "gemini-3.7-flash-high", + "gemini-3.7-flash-medium", + ...EXPECTED_LEADING_MODEL_ORDER, +] as const; + const ACTIVE_FLASH_MODEL_IDS = [ "gemini-3-flash-agent", "gemini-3.5-flash-low", @@ -46,15 +52,15 @@ const CURRENT_36_FLASH_MODEL_IDS = [ "gemini-3.6-flash-low", ] as const; -test("Antigravity and AGY place the live Gemini 3.6 default tiers first", () => { - for (const [provider, models] of [ - ["antigravity", ANTIGRAVITY_PUBLIC_MODELS], - ["agy", AGY_PUBLIC_MODELS], +test("Antigravity and AGY place their live Gemini Flash tiers first", () => { + for (const [provider, models, expectedOrder] of [ + ["antigravity", ANTIGRAVITY_PUBLIC_MODELS, EXPECTED_ANTIGRAVITY_LEADING_MODEL_ORDER], + ["agy", AGY_PUBLIC_MODELS, EXPECTED_LEADING_MODEL_ORDER], ] as const) { assert.deepEqual( - models.slice(0, EXPECTED_LEADING_MODEL_ORDER.length).map((model) => model.id), - EXPECTED_LEADING_MODEL_ORDER, - `${provider} public catalog must place the live Gemini 3.6 default tiers first` + models.slice(0, expectedOrder.length).map((model) => model.id), + expectedOrder, + `${provider} public catalog must place its live Gemini Flash tiers first` ); } }); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index 8a4725eae7..93f80274e5 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -171,8 +171,10 @@ test("Antigravity Gemini 3.5 upstream IDs share the Flash capability profile", ( } }); -test("Antigravity Gemini 3.6 tier IDs share the Flash capability profile", () => { +test("Antigravity Gemini 3.7 and 3.6 tier IDs share the Flash capability profile", () => { for (const modelId of [ + "gemini-3.7-flash-high", + "gemini-3.7-flash-medium", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index d46cecf631..f959807e8f 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -954,6 +954,9 @@ test("provider models route retries Antigravity discovery endpoints before retur models: [ { id: "gemini-3.1-pro-high", displayName: "Gemini 3.1 Pro (High)" }, { id: "gemini-pro-agent", displayName: "Gemini 3.1 Pro (High)" }, + { id: "gemini-3.7-flash-high", displayName: "Gemini 3.7 Flash High" }, + { id: "gemini-3.7-flash-medium", displayName: "Gemini 3.7 Flash Medium" }, + { id: "gemini-3.8-flash-high", displayName: "Gemini 3.8 Flash High" }, { id: "gemini-3.6-flash-high", displayName: "upstream-3.6-high" }, { id: "gemini-3.6-flash-medium", displayName: "upstream-3.6-medium" }, { id: "gemini-3.6-flash-low", displayName: "upstream-3.6-low" }, @@ -987,6 +990,9 @@ test("provider models route retries Antigravity discovery endpoints before retur // #9106: both alias ids are user-callable now, so the upstream echo survives the filter. { id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)" }, { id: "gemini-pro-agent", name: "Gemini 3.1 Pro (High)" }, + { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)" }, + { id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)" }, + { id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash High" }, { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)" }, { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)" }, { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash (Low)" }, diff --git a/tests/unit/t28-model-catalog-updates.test.ts b/tests/unit/t28-model-catalog-updates.test.ts index 6c9229920b..78da2a8ef9 100644 --- a/tests/unit/t28-model-catalog-updates.test.ts +++ b/tests/unit/t28-model-catalog-updates.test.ts @@ -24,6 +24,8 @@ test("T28: antigravity static catalog exposes only callable Gemini tier IDs", () const staticIds = (getStaticModelsForProvider("antigravity") || []).map((m) => m.id); assert.ok(!staticIds.includes("gemini-3-pro-preview")); + assert.ok(staticIds.includes("gemini-3.7-flash-high")); + assert.ok(staticIds.includes("gemini-3.7-flash-medium")); assert.ok(staticIds.includes("gemini-3.6-flash-high")); assert.ok(staticIds.includes("gemini-3.6-flash-medium")); assert.ok(staticIds.includes("gemini-3.6-flash-low")); From 6d9336088c48fe7d9c858afb4e95ff28932c047a Mon Sep 17 00:00:00 2001 From: Brandon Bennett <107384180+branben@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:46:13 -0400 Subject: [PATCH 36/36] fix(chat-body-admission): process-wide budget (#10110) (#10322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat-body-admission): process-wide budget (#10110) Remove per-session admission lanes that multiplied the documented "in one process" heavy/bytes bound by up to 64. All requests now admit against ONE process-global ChatAdmissionController so the bound holds against fake-credential sharding. Per-request session identity survives only as a fairness scheduling key: waiters are grouped per key and served round-robin (#9654) against the shared budget — one connection's burst cannot starve others. - src/shared/middleware/chatBodyAdmission.ts: delete lane map + LRU/TTL eviction; ChatAdmissionController is now the global budget with per-key FIFO queues + round-robin dispatchFair(). PerConnectionAdmissionController returns the same shared controller for every session. resolveSessionId stays as a scheduling key with honest re-scoping docs. snapshot() emits process-wide aggregates. - tests/unit/chat-body-admission-aggregate-10110.test.ts: new U6 suite — 6 deterministic tests (LRU-no-mint, TTL-no-mint, shared byte budget, 16 MiB config, same-session recreation, round-robin fairness). RED on release/v3.8.50, GREEN post-fix. - tests/unit/per-connection-admission-9654.test.ts: rewrite the tests that encoded the defect (per-session isolation) to assert the global-budget contract. - docs/reference/ENVIRONMENT.md: OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES documented as process-wide; VIRTUAL_TTL_MS/VIRTUAL_MAX_SESSIONS deprecated. * docs(changelog): add #10322 fragment for process-wide admission budget * ci: retrigger checks after transient npm ci network failure in shard 3/4 (ETIMEDOUT) --------- Co-authored-by: Brandon Bennett --- .../10322-process-wide-admission-budget.md | 1 + docs/reference/ENVIRONMENT.md | 6 +- src/shared/middleware/chatBodyAdmission.ts | 277 ++++++++++-------- ...hat-body-admission-aggregate-10110.test.ts | 271 +++++++++++++++++ .../per-connection-admission-9654.test.ts | 154 +++++----- 5 files changed, 518 insertions(+), 191 deletions(-) create mode 100644 changelog.d/fixes/10322-process-wide-admission-budget.md create mode 100644 tests/unit/chat-body-admission-aggregate-10110.test.ts diff --git a/changelog.d/fixes/10322-process-wide-admission-budget.md b/changelog.d/fixes/10322-process-wide-admission-budget.md new file mode 100644 index 0000000000..defab2a7fe --- /dev/null +++ b/changelog.d/fixes/10322-process-wide-admission-budget.md @@ -0,0 +1 @@ +- **fix(chat-body-admission):** restore a single process-wide admission budget — heavyweight leases and queued bytes are now bounded once for the whole process instead of per session, so one session can no longer mint extra capacity or starve others; per-session fairness is preserved via round-robin dispatch ([#10110](https://github.com/diegosouzapw/OmniRoute/issues/10110)) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 1592a760fb..5c89b3acc6 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -1501,9 +1501,9 @@ These settings were introduced after the previous environment-contract snapshot. | Variable | Default | Source File | Description | | --- | --- | --- | --- | | `OMNIROUTE_CHAT_ADMISSION_QUEUE_MS` | `2000` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum wait for a heavyweight chat admission slot before a retryable `503`; a short bounded wait serializes agent bursts instead of an instant `503`. `0` restores immediate rejection. | -| `OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES` | `4194304` (4 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Queued-bytes budget for the admission wait (#9654): bounds total buffered body bytes parked per lane so the wait cannot amplify the heap (#4380). Over-budget waits receive a retryable `503` immediately. | -| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): idle-lane eviction TTL. | -| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). | +| `OMNIROUTE_CHAT_ADMISSION_MAX_QUEUED_BYTES` | `4194304` (4 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Queued-bytes budget for the admission wait: bounds total buffered body bytes parked process-wide so the wait cannot amplify the heap (#4380). Over-budget waits receive a retryable `503` immediately. | +| `OMNIROUTE_CHAT_VIRTUAL_TTL_MS` | `60000` (60 s) | `src/shared/middleware/chatBodyAdmission.ts` | Deprecated no-op since #10110: per-session admission lanes were removed in favor of one process-wide budget. Accepted for configuration compatibility; ignored. | +| `OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Deprecated no-op since #10110: per-session admission lanes were removed in favor of one process-wide budget. Accepted for configuration compatibility; ignored. | | `OMNIROUTE_RUNNOW_TIMEOUT_MS` | `30000` | `src/app/api/jobs/[id]/run-now/route.ts` | Bounds how long a run-now call waits for an in-flight job before starting the queued run. | | `ADOBE_FIREFLY_BROWSER_REFRESH` | enabled | `open-sse/services/adobeFireflySession.ts` | Keeps IMS and browser-risk state fresh through account-scoped Chrome CDP sessions; set `0` to disable. | | `ADOBE_FIREFLY_SESSION_DISK` | enabled | `open-sse/services/adobeFireflySession.ts` | Persists repaired Adobe sessions under `DATA_DIR`; set `0` for memory-only state. | diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts index 290a18f6db..e85fce30ec 100644 --- a/src/shared/middleware/chatBodyAdmission.ts +++ b/src/shared/middleware/chatBodyAdmission.ts @@ -7,20 +7,17 @@ * local heavyweight capacity before parsing and enforces the hard limit against bytes read, * not an untrusted Content-Length header. * - * Per-connection virtual admission lanes (#9654): each distinct API-key (or anonymous) - * bucket gets its own FairCostQueue so one connection cannot exhaust heavyweight capacity - * and starve others. Idle sessions are auto-evicted after a TTL. + * Process-wide admission budget (#10110): ALL requests — every API key, every + * session — contend for ONE global heavyweight budget, so the documented + * "in one process" bound holds against fake-credential sharding. Per-request + * session identity is used only as a fairness scheduling key: waiters are + * grouped per session and served round-robin against the shared budget, so one + * connection's burst cannot starve others (#9654). */ import { CORS_HEADERS } from "../utils/cors"; import { createHash } from "crypto"; - -const OMNIROUTE_CHAT_VIRTUAL_TTL_MS = parsePositiveInt( - process.env.OMNIROUTE_CHAT_VIRTUAL_TTL_MS, - 60_000 -); - function parsePositiveInt(value: string | undefined, fallback: number): number { const parsed = Number.parseInt(String(value), 10); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; @@ -109,6 +106,12 @@ export interface ChatAdmissionLease { release(): void; } +/** A parked waiter, grouped by fairness key for round-robin dispatch. */ +interface AdmissionWaiter { + readonly key: string; + readonly resolve: () => void; +} + /** * Process-local heavyweight reservation. The capacity check and increment execute in one * synchronous JavaScript turn, making acquisition atomic within an OmniRoute process. @@ -119,7 +122,13 @@ export interface ChatAdmissionLease { export class ChatAdmissionController { #activeHeavy = 0; #queuedBytes = 0; - #waiters: Array<() => void> = []; + /** Per-key FIFOs. A key groups one client's waiters so they are served + * round-robin against the shared budget instead of monopolizing a strict + * FIFO (see #dispatchFair). */ + #queues = new Map(); + /** Keys in creation order; #fairCursor scans them round-robin. */ + #fairKeys: string[] = []; + #fairCursor = 0; constructor( readonly maxHeavyInFlight = 1, @@ -137,11 +146,25 @@ export class ChatAdmissionController { return this.#activeHeavy; } - /** Total buffered bytes currently parked in the FIFO (heap valve accounting). */ + /** Total buffered bytes currently parked across all queues (heap valve accounting). */ get queuedBytes(): number { return this.#queuedBytes; } + /** Total waiters parked across all keys (diagnostics). */ + get waitingCount(): number { + let total = 0; + for (const queue of this.#queues.values()) total += queue.length; + return total; + } + + /** Per-key waiter depths (diagnostics) — opaque scheduler keys, never raw credentials. */ + get waitersByKey(): ReadonlyArray<{ key: string; waiting: number }> { + const out: Array<{ key: string; waiting: number }> = []; + for (const [key, queue] of this.#queues) out.push({ key, waiting: queue.length }); + return out; + } + tryAcquireHeavy(): ChatAdmissionLease | null { if (this.#activeHeavy >= this.maxHeavyInFlight) return null; this.#activeHeavy += 1; @@ -154,7 +177,7 @@ export class ChatAdmissionController { if (released) return; released = true; this.#activeHeavy = Math.max(0, this.#activeHeavy - 1); - this.#waiters.shift()?.(); + this.#dispatchFair(); }, }; } @@ -163,10 +186,14 @@ export class ChatAdmissionController { * Wait up to `timeoutMs` for heavyweight capacity, retrying atomically on each * release. Resolves `null` when the deadline expires with no capacity freed, in * which case the caller answers the retryable 503. `timeoutMs <= 0` is the - * legacy immediate-reject path. Waiters are served FIFO. + * legacy immediate-reject path. + * + * Waiters are grouped by `sessionKey` and served round-robin across keys + * (#dispatchFair), so one client's burst cannot starve another's bounded wait + * while every key contends for the SAME process-wide budget. * * When `signal` aborts while parked (client disconnect), the waiter is removed - * from the FIFO immediately and the promise resolves `null` early instead of + * from its queue immediately and the promise resolves `null` early instead of * parking for the full `timeoutMs` — the caller's 503 is dropped on the dead * connection, so no capacity is consumed and the freed slot never wakes a * waiter the client no longer needs. A signal that is already aborted never @@ -181,7 +208,8 @@ export class ChatAdmissionController { async acquireHeavyWithin( timeoutMs: number, signal?: AbortSignal, - queuedBytes = 0 + queuedBytes = 0, + sessionKey = "default" ): Promise { const deadline = Date.now() + Math.max(0, Math.floor(timeoutMs)); for (;;) { @@ -195,14 +223,26 @@ export class ChatAdmissionController { return null; } this.#queuedBytes += queuedBytes; - let resolver: (() => void) | null = null; - const released = new Promise((resolve) => { - resolver = () => resolve(); - this.#waiters.push(resolver); + // Park into this key's FIFO (creating the key on first use). + let queue = this.#queues.get(sessionKey); + if (!queue) { + queue = []; + this.#queues.set(sessionKey, queue); + this.#fairKeys.push(sessionKey); + } + const lane = queue; + let resolveParked: (() => void) | null = null; + const waiter: AdmissionWaiter = { + key: sessionKey, + resolve: () => resolveParked?.(), + }; + const parked = new Promise((resolve) => { + resolveParked = () => resolve(); + lane.push(waiter); }); let deadlineTimer: ReturnType | null = null; const races: Array> = [ - released.then(() => false), + parked.then(() => false), new Promise((resolve) => { deadlineTimer = setTimeout(() => resolve(true), remaining); }), @@ -220,41 +260,80 @@ export class ChatAdmissionController { ); } const timedOut = await Promise.race(races); - // The waiter has left the FIFO (wake, abort, or timeout) — release its charge. + // The waiter has left its queue (wake, abort, or timeout) — release its charge. this.#queuedBytes = Math.max(0, this.#queuedBytes - queuedBytes); - if (resolver) { - const index = this.#waiters.indexOf(resolver); - if (index >= 0) this.#waiters.splice(index, 1); - } + this.#removeWaiter(waiter); // Cancel the deadline timer when abort/release wins; a fired timer is a no-op. if (deadlineTimer) clearTimeout(deadlineTimer); if (onAbort) signal?.removeEventListener("abort", onAbort); if (timedOut) return null; } } + + /** Remove a parked waiter from its key's queue, dropping empty keys. Idempotent. */ + #removeWaiter(waiter: AdmissionWaiter): void { + const queue = this.#queues.get(waiter.key); + if (!queue) return; + const index = queue.indexOf(waiter); + if (index >= 0) queue.splice(index, 1); + if (queue.length === 0) this.#removeFairKey(waiter.key); + } + + #removeFairKey(key: string): void { + this.#queues.delete(key); + const index = this.#fairKeys.indexOf(key); + if (index < 0) return; + this.#fairKeys.splice(index, 1); + if (index < this.#fairCursor) this.#fairCursor -= 1; + if (this.#fairKeys.length === 0) this.#fairCursor = 0; + } + + /** + * Round-robin dispatch across per-key queues (#9654 fairness, #10110 global + * budget). Called on every release; wakes exactly ONE waiter — the head of + * the next key in rotation — so the freed slot is claimed atomically by the + * woken waiter's re-loop. A strict FIFO would let one client's burst consume + * every freed slot; rotating the cursor gives each contending key a turn. + */ + #dispatchFair(): void { + if (this.#fairKeys.length === 0) return; + for (let i = 0; i < this.#fairKeys.length; i++) { + const key = this.#fairKeys[this.#fairCursor % this.#fairKeys.length]; + this.#fairCursor += 1; + const queue = this.#queues.get(key); + if (!queue || queue.length === 0) continue; + const waiter = queue.shift() as AdmissionWaiter; + if (queue.length === 0) this.#removeFairKey(key); + waiter.resolve(); + return; + } + } } const defaultAdmissionController = new ChatAdmissionController(CHAT_MAX_HEAVY_IN_FLIGHT); /** - * Per-connection virtual admission lanes (#9654). + * Process-wide byte-level admission budget (#10110). * - * Maps a sessionId (API-key hash or "anonymous") → ChatAdmissionController. - Each connection gets its own bounded heavyweight capacity so one connection - * cannot exhaust `CHAT_MAX_HEAVY_IN_FLIGHT` and starve others at the byte-level - * admission stage. + * Every request — every session, every API key — admits against ONE global + * ChatAdmissionController, so `CHAT_MAX_HEAVY_IN_FLIGHT` and + * `CHAT_ADMISSION_MAX_QUEUED_BYTES` are enforced process-wide, exactly as + * documented in docs/reference/ENVIRONMENT.md. The pre-#10110 design minted a + * per-session controller per request, multiplying the process bound by up to + * 64 lanes and letting unauthenticated fake credentials shard capacity. * - * Idle sessions are auto-evicted after OMNIROUTE_CHAT_VIRTUAL_TTL_MS - * (default 60s) to prevent unbounded Map growth. + * Per-request session identity survives ONLY as a fairness scheduling key: + * waiters are grouped per key and served round-robin against the shared + * budget (ChatAdmissionController#dispatchFair), preserving the #9654 + * guarantee that one connection's burst cannot starve others — without any + * per-key capacity being allocated. */ -const OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS = parsePositiveInt( - process.env.OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS, - 64 -); export function resolveSessionId(request: Request): string { - // Reuse the existing internal-bypass auth extraction: bearer token from - // Authorization, x-api-key (Anthropic-style), or Google API key header. + // Fairness scheduling key ONLY (never a capacity shard): hashed so raw key + // material never appears in diagnostics. Reuses the internal-bypass auth + // extraction: bearer token from Authorization, x-api-key (Anthropic-style), + // or Google API key header. const authHeader = request.headers.get("authorization") || ""; const bearerMatch = /^bearer\s+(\S+)$/i.exec(authHeader.trim()); if (bearerMatch) { @@ -271,106 +350,63 @@ export function resolveSessionId(request: Request): string { return "anonymous"; } -interface SessionRecord { - controller: ChatAdmissionController; - lastUsedMs: number; -} - export class PerConnectionAdmissionController { - #sessions = new Map(); - #evictionTimer: ReturnType | null = null; - readonly maxSessions: number; - readonly sessionTtlMs: number; + readonly #controller: ChatAdmissionController; constructor( - readonly maxHeavyPerSession: number, - opts?: { maxSessions?: number; sessionTtlMs?: number } + readonly maxHeavyInFlight = 1, + // Deprecated pre-#10110 lane-eviction knobs: accepted for API + // compatibility and ignored — there are no per-session lanes to evict. + _opts?: { maxSessions?: number; sessionTtlMs?: number } ) { - this.maxSessions = opts?.maxSessions ?? OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS; - this.sessionTtlMs = opts?.sessionTtlMs ?? OMNIROUTE_CHAT_VIRTUAL_TTL_MS; + this.#controller = new ChatAdmissionController(maxHeavyInFlight); } - getController(sessionId: string): ChatAdmissionController { - this.evictIfDue(); - const existing = this.#sessions.get(sessionId); - if (existing) { - existing.lastUsedMs = Date.now(); - return existing.controller; - } - // Evict oldest if at capacity (LRU fallback when TTL hasn't fired). - if (this.#sessions.size >= this.maxSessions) { - const oldestKey = this.oldestKey(); - if (oldestKey) this.#sessions.delete(oldestKey); - } - const controller = new ChatAdmissionController(this.maxHeavyPerSession); - this.#sessions.set(sessionId, { controller, lastUsedMs: Date.now() }); - this.armEviction(); - return controller; + /** Returns the process-global budget — the same instance for every session. */ + getController(_sessionId: string): ChatAdmissionController { + return this.#controller; } - /** Snapshot for observability — never exposes raw API keys. */ - snapshot(): ReadonlyArray<{ sessionId: string; activeHeavy: number; idleMs: number }> { - const now = Date.now(); - const arr: Array<{ sessionId: string; activeHeavy: number; idleMs: number }> = []; - for (const [sessionId, record] of this.#sessions) { - arr.push({ - sessionId, - activeHeavy: record.controller.activeHeavy, - idleMs: now - record.lastUsedMs, - }); - } - return arr; + /** + * Process-wide aggregate snapshot for observability: global totals plus + * per-key waiter depths. Keys are opaque scheduler keys, never raw + * credentials. + */ + snapshot(): { + activeHeavy: number; + queuedBytes: number; + waiting: number; + lanes: ReadonlyArray<{ key: string; waiting: number }>; + } { + return { + activeHeavy: this.#controller.activeHeavy, + queuedBytes: this.#controller.queuedBytes, + waiting: this.#controller.waitingCount, + lanes: this.#controller.waitersByKey, + }; } - get sessionCount(): number { - return this.#sessions.size; + get activeHeavy(): number { + return this.#controller.activeHeavy; } - private oldestKey(): string | undefined { - let oldest: string | undefined; - let oldestMs = Infinity; - for (const [key, record] of this.#sessions) { - // Use <= so that for equal timestamps, later-inserted entries win, - // preserving LRU semantics when Date.now() returns the same value. - if (record.lastUsedMs <= oldestMs) { - oldestMs = record.lastUsedMs; - oldest = key; - } - } - return oldest; + get queuedBytes(): number { + return this.#controller.queuedBytes; } - private evictIfDue(): void { - const now = Date.now(); - let evicted = false; - for (const [sessionId, record] of this.#sessions) { - if (now - record.lastUsedMs >= this.sessionTtlMs) { - this.#sessions.delete(sessionId); - evicted = true; - } - } - if (evicted) this.armEviction(); + get waitingCount(): number { + return this.#controller.waitingCount; } - private armEviction(): void { - if (this.#evictionTimer !== null) return; - this.#evictionTimer = setTimeout(() => { - this.#evictionTimer = null; - this.evictIfDue(); - }, this.sessionTtlMs).unref(); - } - - /** Force cleanup of all sessions (used by shutdown / tests). */ + /** No per-session state to clean; kept for API compatibility. */ dispose(): void { - this.#sessions.clear(); - if (this.#evictionTimer !== null) { - clearTimeout(this.#evictionTimer); - this.#evictionTimer = null; - } + // Intentionally empty: the process-global controller owns no session state. } } -export const perConnectionAdmissionController = new PerConnectionAdmissionController(CHAT_MAX_HEAVY_IN_FLIGHT); +export const perConnectionAdmissionController = new PerConnectionAdmissionController( + CHAT_MAX_HEAVY_IN_FLIGHT +); export type ChatRequestAdmission = | { admit: true; request: Request; lease: ChatAdmissionLease | null } @@ -530,7 +566,8 @@ export async function admitChatStructure( const acquired = await controller.acquireHeavyWithin( options.queueMs ?? 0, options.signal, - CHAT_LARGE_BODY_BYTES + CHAT_LARGE_BODY_BYTES, + options.sessionId ); return acquired ? { admit: true, lease: acquired } @@ -700,7 +737,7 @@ export async function admitChatRequest( let lease: ChatAdmissionLease | null = null; const reserve = async (bytes = 0): Promise => { if (lease) return true; - lease = await controller.acquireHeavyWithin(queueMs, request.signal, bytes); + lease = await controller.acquireHeavyWithin(queueMs, request.signal, bytes, sessionId); return lease !== null; }; diff --git a/tests/unit/chat-body-admission-aggregate-10110.test.ts b/tests/unit/chat-body-admission-aggregate-10110.test.ts new file mode 100644 index 0000000000..22e67fd763 --- /dev/null +++ b/tests/unit/chat-body-admission-aggregate-10110.test.ts @@ -0,0 +1,271 @@ +// #10110: Aggregate process-wide bounds for byte-level chat admission. +// +// The always-on per-connection admission layer (#9940 / #9654) enforces +// CHAT_MAX_HEAVY_IN_FLIGHT and CHAT_ADMISSION_MAX_QUEUED_BYTES PER LANE, so the +// documented "in one process" contract (docs/reference/ENVIRONMENT.md:193) is +// multiplied by OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS (default 64): up to 64 +// concurrent heavy requests and 256 MiB of parked bodies process-wide. +// +// These tests assert the AGGREGATE contract that the issue's acceptance +// criteria demand. They are intentionally deterministic (exact === assertions, +// no <= fudge) and are RED on release/v3.8.50 — they only pass once the byte +// level admits against one process-global budget with per-key fairness. +import test from "node:test"; +import assert from "node:assert/strict"; + +const admissionModule = await import("../../src/shared/middleware/chatBodyAdmission.ts"); +const { PerConnectionAdmissionController, CHAT_ADMISSION_MAX_QUEUED_BYTES } = admissionModule; + +const GLOBAL_QUEUED_BUDGET = CHAT_ADMISSION_MAX_QUEUED_BYTES; // 4 MiB default + +// Aggregate across distinct controllers. With the fix every key resolves to +// ONE process-global controller (shared budget), so dedupe-by-identity yields +// the true process-wide totals — never double-counted, never multiplied by +// the number of keys. +function aggregateActiveHeavy( + pc: InstanceType, + keys: string[] +): number { + const seen = new Set(); + let total = 0; + for (const key of keys) { + const controller = pc.getController(key); + if (seen.has(controller)) continue; + seen.add(controller); + total += controller.activeHeavy; + } + return total; +} + +function aggregateQueuedBytes( + pc: InstanceType, + keys: string[] +): number { + const seen = new Set(); + let total = 0; + for (const key of keys) { + const controller = pc.getController(key); + if (seen.has(controller)) continue; + seen.add(controller); + total += controller.queuedBytes; + } + return total; +} + +// ── Family 1: active-LRU eviction must not mint replacement capacity ────── +// Issue repro: maxSessions=1, key A acquires; key B admission LRU-evicts A; +// A re-admits and gets a FRESH controller with fresh capacity while the old +// lease still holds → effectiveActiveForA = 2. The aggregate must stay 1. + +test("LRU eviction of a live lane does not mint a second capacity slot", () => { + // Red on release/v3.8.50: B's admission LRU-evicts A's lane, and A's re-admit + // gets a FRESH controller with fresh capacity while the old lease still holds. + // With the fix there are no per-session lanes at all: getController returns the + // one shared process-global controller, so no capacity can ever be minted. + const pc = new PerConnectionAdmissionController(1, { maxSessions: 1, sessionTtlMs: 60_000 }); + + const ctrlA1 = pc.getController("A"); + const leaseA = ctrlA1.tryAcquireHeavy(); + assert.ok(leaseA, "A acquires the only slot"); + + // B's admission must NOT evict the lane holding a live lease; and even if + // the lane is retired/recreated, it must not mint fresh capacity. + pc.getController("B"); + + // A re-admits: no fresh capacity may appear while the old lease is live. + const ctrlA2 = pc.getController("A"); + assert.equal( + ctrlA2.tryAcquireHeavy(), + null, + "a live lease must keep its slot; no second capacity may be minted" + ); + + // Aggregate active heavy across every lane stays at the process-wide bound. + assert.equal( + aggregateActiveHeavy(pc, ["A", "B"]), + 1, + "process-wide active heavy must be 1, not 2 (orphaned lease + minted slot)" + ); + + leaseA.release(); +}); + +// ── Family 2: active-TTL eviction must not mint replacement capacity ────── +// Same invariant via the idle-TTL path: a lane that still holds a live lease +// must not be evicted (or, if retired, must not hand out fresh capacity). + +test("TTL eviction of a live lane does not mint a second capacity slot", async () => { + // Red on release/v3.8.50: the idle-TTL evicts A's lane mid-lease; a re-admit + // then mints a fresh controller with fresh capacity (orphaned lease + new slot). + // With the fix the shared controller outlives any session and never mints. + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 50 }); + + const ctrlA1 = pc.getController("A"); + const leaseA = ctrlA1.tryAcquireHeavy(); + assert.ok(leaseA, "A acquires the only slot"); + + // Wait past the idle TTL so evictIfDue() would mark A's lane stale. + await new Promise((resolve) => setTimeout(resolve, 120)); + + // Re-admitting A must not produce a controller with fresh capacity. + const ctrlA2 = pc.getController("A"); + assert.equal( + ctrlA2.tryAcquireHeavy(), + null, + "a live lease must survive TTL; no fresh capacity may be minted" + ); + assert.equal( + aggregateActiveHeavy(pc, ["A"]), + 1, + "process-wide active heavy must stay 1 after TTL with a live lease" + ); + + leaseA.release(); +}); + +// ── Family 3: aggregate parked bytes stay within the process-wide budget ── +// Regression guard for the byte side of the multiplication: waiters parked +// from DIFFERENT lanes must share ONE process-wide queued-bytes budget. +// +// Note on shape: on the buggy code an idle lane never parks (its waiter +// acquires on its own free capacity instantly), so cross-lane bytes are only +// observable while multiple lanes are simultaneously busy — an arrangement +// the global-budget fix makes impossible by construction. The active-heavy +// families (1/2/4) are the RED probes; this family locks in the byte budget +// once the shared budget exists: one busy slot + waiters parked from two +// lanes, aggregate must never exceed the single process-wide budget. + +test("parked bytes across lanes share one process-wide budget", async () => { + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 60_000 }); + + // One lane holds the single busy slot. + const ctrlA = pc.getController("A"); + const heldA = ctrlA.tryAcquireHeavy(); + assert.ok(heldA); + + // Two waiters park — one keyed A, one keyed B — against the SAME busy slot. + // Together they must respect the single process-wide budget. + const wA = ctrlA.acquireHeavyWithin(2_000, undefined, GLOBAL_QUEUED_BUDGET, "A"); + const wB = pc.getController("B").acquireHeavyWithin(2_000, undefined, GLOBAL_QUEUED_BUDGET, "B"); + await new Promise((resolve) => setTimeout(resolve, 30)); + + const aggregate = aggregateQueuedBytes(pc, ["A", "B"]); + assert.ok( + aggregate <= GLOBAL_QUEUED_BUDGET, + `aggregate queued bytes (${aggregate}) must stay within the single process-wide budget (${GLOBAL_QUEUED_BUDGET})` + ); + + heldA.release(); + const leases = await Promise.all([wA, wB]); + for (const lease of leases) lease?.release(); + assert.equal(aggregateQueuedBytes(pc, ["A", "B"]), 0, "all parked bytes released"); +}); + +test("a 16 MiB per-lane config still respects the process-wide byte budget", async () => { + // The issue's 1 GiB scenario shape: per-lane budgets that would multiply + // into 1 GiB must instead be capped by the single process-wide budget. + const MiB = 1024 * 1024; + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 60_000 }); + + const ctrlA = pc.getController("A"); + const heldA = ctrlA.tryAcquireHeavy(); + assert.ok(heldA); + + const wA = ctrlA.acquireHeavyWithin(2_000, undefined, 16 * MiB, "A"); + const wB = pc.getController("B").acquireHeavyWithin(2_000, undefined, 16 * MiB, "B"); + await new Promise((resolve) => setTimeout(resolve, 30)); + + const aggregate = aggregateQueuedBytes(pc, ["A", "B"]); + assert.ok( + aggregate <= GLOBAL_QUEUED_BUDGET, + `16 MiB per-lane config must still respect the process-wide budget; aggregate was ${aggregate}` + ); + + heldA.release(); + const leases = await Promise.all([wA, wB]); + for (const lease of leases) lease?.release(); +}); + +// ── Family 4: same-session recreation waits on the global slot ──────────── +// A session that released and re-admits while ANOTHER session holds the +// process-wide slot must queue, not bypass. + +test("same-session recreation waits while another session holds the global slot", () => { + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 60_000 }); + + const ctrlA = pc.getController("A"); + const leaseA = ctrlA.tryAcquireHeavy(); + assert.ok(leaseA); + leaseA.release(); // A releases; the global slot is free. + + // B takes the single process-wide slot. + const ctrlB = pc.getController("B"); + const leaseB = ctrlB.tryAcquireHeavy(); + assert.ok(leaseB); + + // A re-admits while B holds the slot → must wait, not bypass. + assert.equal( + ctrlA.tryAcquireHeavy(), + null, + "recreated A must not bypass the process-wide slot held by B" + ); + assert.equal( + aggregateActiveHeavy(pc, ["A", "B"]), + 1, + "aggregate active heavy is 1 with B holding the slot" + ); + + leaseB.release(); +}); + +// ── Fairness guard (B2): lanes are served round-robin over the shared budget ─ +// A session that queues a burst must not consume every dispatch turn: when A +// queues two waiters and B queues one behind the same busy slot, B's waiter +// must be served BEFORE A's second follow-up (round-robin across lanes, the +// adaptive dispatchLanes precedent). A strict single FIFO would serve A-A-B +// and starve B under sustained load. + +test("one session's burst does not starve another session's bounded wait", async () => { + const pc = new PerConnectionAdmissionController(1, { maxSessions: 64, sessionTtlMs: 60_000 }); + + // A holds the single slot and queues two follow-ups. + const ctrlA = pc.getController("A"); + const leaseA = ctrlA.tryAcquireHeavy(); + assert.ok(leaseA); + + const aWaiters = [ + ctrlA.acquireHeavyWithin(2_000, undefined, 0, "A"), + ctrlA.acquireHeavyWithin(2_000, undefined, 0, "A"), + ]; + + // B queues one bounded wait behind the shared slot. + const ctrlB = pc.getController("B"); + const bWaiter = ctrlB.acquireHeavyWithin(2_000, undefined, 0, "B"); + + // Free the slot, then release each lease the moment it arrives so the next + // waiter can proceed. Record acquisition order. + leaseA.release(); + const order: string[] = []; + const track = (label: string) => (lease: unknown) => { + if (lease) { + order.push(label); + (lease as { release: () => void }).release(); + } + }; + void aWaiters[0].then(track("a1")); + void aWaiters[1].then(track("a2")); + void bWaiter.then(track("b1")); + await Promise.all([...aWaiters, bWaiter]); + + assert.equal( + order.length, + 3, + "all three queued sessions must acquire within the bounded wait; none starve" + ); + assert.equal( + order.indexOf("b1"), + 1, + `round-robin must serve B before A's second follow-up (strict FIFO would starve B); got order ${order.join(" -> ")}` + ); + assert.equal(aggregateActiveHeavy(pc, ["A", "B"]), 0); +}); diff --git a/tests/unit/per-connection-admission-9654.test.ts b/tests/unit/per-connection-admission-9654.test.ts index 8f26b88129..ccfd494c0a 100644 --- a/tests/unit/per-connection-admission-9654.test.ts +++ b/tests/unit/per-connection-admission-9654.test.ts @@ -1,4 +1,10 @@ -// #9654: Per-connection virtual admission lanes +// #9654/#10110: process-wide admission budget with per-key fair scheduling. +// +// The pre-#10110 per-connection lanes minted a controller per session, so the +// documented "in one process" heavy/bytes bound multiplied by up to 64 lanes +// (#10110). The fix removes the lanes entirely: every session resolves to ONE +// process-global controller, and per-request session identity is used only as +// a fairness scheduling key (round-robin dispatch, not capacity allocation). import test from "node:test"; import assert from "node:assert/strict"; @@ -10,7 +16,6 @@ const { admitChatStructure, perConnectionAdmissionController, ChatAdmissionController, - CHAT_MAX_HEAVY_IN_FLIGHT, } = admissionModule; function makeRequest(headers: Record, body = "{}"): Request { @@ -50,21 +55,26 @@ test("resolveSessionId does not leak raw API key in the session ID", () => { assert.ok(!sid.includes("secret")); }); -test("PerConnectionAdmissionController isolates capacity across sessions", () => { +// ── #10110: one process-global budget, shared by every session ──────────── +// The pre-fix lanes isolated capacity per session (up to 64× the process +// bound, and fake credentials could shard capacity). The fix returns the SAME +// controller for every session so the process-wide bound is real. + +test("PerConnectionAdmissionController shares ONE global budget across sessions", () => { const pc = new PerConnectionAdmissionController(1); const ctrlA = pc.getController("session-a"); const ctrlB = pc.getController("session-b"); - // Session A acquires the only slot + // A acquires the only process-wide slot. const leaseA = ctrlA.tryAcquireHeavy(); assert.ok(leaseA); - // Session A is now full - assert.equal(ctrlA.tryAcquireHeavy(), null); - // Session B still has capacity — isolation works - const leaseB = ctrlB.tryAcquireHeavy(); - assert.ok(leaseB); + // B shares the SAME budget: no per-session capacity is available while A + // holds the single process-wide slot (pre-#10110 this minted B its own slot). + assert.equal(ctrlB.tryAcquireHeavy(), null); leaseA.release(); - leaseB.release(); + // Slot freed → either session may now acquire. + assert.ok(ctrlB.tryAcquireHeavy()); + ctrlB.tryAcquireHeavy()?.release(); }); test("PerConnectionAdmissionController returns same controller for same session", () => { @@ -74,83 +84,93 @@ test("PerConnectionAdmissionController returns same controller for same session" assert.equal(a1, a2); }); -test("PerConnectionAdmissionController creates new controller for new session", () => { +test("PerConnectionAdmissionController returns the same controller for ALL sessions", () => { const pc = new PerConnectionAdmissionController(1); const a = pc.getController("session-a"); const b = pc.getController("session-b"); - assert.notEqual(a, b); + const c = pc.getController("session-c"); + // The shared process-global budget is a single instance (#10110): no per-key + // controllers exist to multiply the bound. + assert.equal(a, b); + assert.equal(b, c); }); -test("PerConnectionAdmissionController enforces maxSessions LRU eviction", () => { - const pc = new PerConnectionAdmissionController(1, { maxSessions: 2, sessionTtlMs: 60000 }); - const a = pc.getController("a"); +test("PerConnectionAdmissionController never evicts a live lease (no lane lifecycle)", () => { + // The pre-#10110 LRU/TTL lane eviction could drop a controller mid-lease and + // mint a fresh one on re-admit — silently doubling capacity (#10110). With a + // single shared controller there is nothing to evict and nothing to mint. + const pc = new PerConnectionAdmissionController(1, { maxSessions: 1, sessionTtlMs: 50 }); + const ctrlA1 = pc.getController("a"); + const leaseA = ctrlA1.tryAcquireHeavy(); + assert.ok(leaseA); + + // Touching another session (LRU pressure) and waiting past the TTL must not + // replace the controller holding the live lease. const b = pc.getController("b"); - assert.equal(pc.sessionCount, 2); - // Touch 'a' so 'b' is oldest + assert.equal(b, ctrlA1, "same global controller, no per-session lane to evict"); const aAgain = pc.getController("a"); - assert.equal(aAgain, a, "same a reference"); - // Creating 'c' should evict 'b' (oldest) - const c = pc.getController("c"); - assert.equal(pc.sessionCount, 2); - // 'a' survives, 'b' is evicted - const aAfter = pc.getController("a"); - assert.equal(aAfter, a, "a should still exist after c added"); - // 'b' gets a fresh controller (old one was evicted) - const newB = pc.getController("b"); - assert.notEqual(newB, b, "b should be evicted and recreated"); + assert.equal(aAgain, ctrlA1, "controller identity is stable across TTL"); + assert.equal(aAgain.tryAcquireHeavy(), null, "live lease keeps the only slot"); + leaseA.release(); }); -test("PerConnectionAdmissionController evicts idle sessions after TTL", async () => { - const pc = new PerConnectionAdmissionController(1, { - sessionTtlMs: 50, - maxSessions: 64, - }); - const ctrl = pc.getController("idle-session"); - assert.ok(ctrl); - assert.equal(pc.sessionCount, 1); - - // Wait past TTL + eviction tick - await new Promise((resolve) => setTimeout(resolve, 120)); - // Accessing again should trigger eviction → fresh controller - const fresh = pc.getController("idle-session"); - assert.notEqual(fresh, ctrl); -}); - -test("PerConnectionAdmissionController snapshot does not leak raw keys", () => { +test("PerConnectionAdmissionController snapshot reports process-wide aggregates", async () => { const pc = new PerConnectionAdmissionController(1); pc.getController("key_abc123"); pc.getController("anonymous"); + + const empty = pc.snapshot(); + assert.equal(empty.activeHeavy, 0); + assert.equal(empty.queuedBytes, 0); + assert.equal(empty.waiting, 0); + assert.deepEqual(empty.lanes, []); + + // Occupy the global slot and park a waiter from a second key. + const ctrlA = pc.getController("key_abc123"); + const leaseA = ctrlA.tryAcquireHeavy(); + assert.ok(leaseA); + const wB = pc.getController("anonymous").acquireHeavyWithin(500, undefined, 100, "anonymous"); + await new Promise((resolve) => setTimeout(resolve, 30)); + const snap = pc.snapshot(); - assert.equal(snap.length, 2); - for (const entry of snap) { - assert.ok(typeof entry.sessionId === "string"); - assert.ok(entry.sessionId.includes("key_abc123") || entry.sessionId === "anonymous"); - assert.ok(typeof entry.activeHeavy === "number"); - assert.ok(typeof entry.idleMs === "number"); + assert.equal(snap.activeHeavy, 1); + assert.equal(snap.queuedBytes, 100); + assert.equal(snap.waiting, 1); + assert.ok(Array.isArray(snap.lanes)); + for (const lane of snap.lanes) { + assert.ok(typeof lane.key === "string"); + assert.ok(lane.waiting === 0 || lane.waiting === 1); + // Keys are opaque hashed scheduler keys — raw credentials never appear. + assert.ok(!lane.key.includes("secret")); } + + const waiterLease = await wB; + waiterLease?.release(); + leaseA.release(); }); test("admitChatRequest uses per-connection controller by default", async () => { - const result = await admitChatRequest( - makeRequest({ authorization: "Bearer sk-test-key" }), - { largeBodyBytes: 32, hardMaxBytes: 1024 } - ); + const result = await admitChatRequest(makeRequest({ authorization: "Bearer sk-test-key" }), { + largeBodyBytes: 32, + hardMaxBytes: 1024, + }); assert.equal(result.admit, true); if (result.admit) result.lease?.release(); }); test("admitChatRequest with explicit controller overrides per-connection lookup", async () => { const explicitController = new ChatAdmissionController(1); - const result = await admitChatRequest( - makeRequest({ authorization: "Bearer sk-test-key" }), - { controller: explicitController, largeBodyBytes: 32, hardMaxBytes: 1024 } - ); + const result = await admitChatRequest(makeRequest({ authorization: "Bearer sk-test-key" }), { + controller: explicitController, + largeBodyBytes: 32, + hardMaxBytes: 1024, + }); assert.equal(result.admit, true); if (result.admit) result.lease?.release(); }); test("admitChatStructure routes structural rejection to per-connection controller", async () => { - // occupy sess-a's per-connection controller via the module-level instance + // occupy sess-a's controller — which is the shared process-global budget const controller = perConnectionAdmissionController.getController("sess-a"); const occupied = controller.tryAcquireHeavy(); assert.ok(occupied); @@ -168,7 +188,7 @@ test("admitChatStructure routes structural rejection to per-connection controlle heavyTokens: 10_000, } ); - // Session A is busy → 503 + // The process-wide slot is busy → 503 assert.equal(result.admit, false); if (result.admit) return; assert.equal(result.response.status, 503); @@ -176,13 +196,14 @@ test("admitChatStructure routes structural rejection to per-connection controlle occupied.release(); }); -test("admitChatStructure with different sessionId gets independent capacity", async () => { - // occupy sess-a's per-connection controller +test("admitChatStructure with different sessionId shares the global budget", async () => { + // occupy the shared process-global budget via sess-a const ctrlA = perConnectionAdmissionController.getController("sess-a"); const occupied = ctrlA.tryAcquireHeavy(); assert.ok(occupied); - // Session B should get its own controller → admitted + // Session B must NOT get independent capacity (pre-#10110 it did — that was + // the defect): it shares the one process-wide slot and must be rejected. const result = await admitChatStructure( { messages: Array.from({ length: 500 }, () => ({ role: "user", content: "x" })), @@ -196,10 +217,7 @@ test("admitChatStructure with different sessionId gets independent capacity", as heavyTokens: 32_000, } ); - assert.equal(result.admit, true); - if (result.admit) { - assert.notEqual(result.lease, null); - result.lease?.release(); - } + assert.equal(result.admit, false); + assert.equal(result.response.status, 503); occupied.release(); });