From 5a7df8ac292f92f2af8162a71e4f0aa4ba5f97e0 Mon Sep 17 00:00:00 2001 From: Raxxoor Date: Sun, 17 May 2026 01:47:40 +0100 Subject: [PATCH] fix: harden stream readiness and build output (#2317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — fixes stream readiness detection for OpenAI Responses API lifecycle events, GLM timeout, Provider Limits UI, and build output cleanup. --- bin/cli/runtime/sqliteRuntime.mjs | 9 +- next.config.mjs | 17 ++ open-sse/executors/glm.ts | 4 +- open-sse/lib/deepseek-pow.ts | 7 +- open-sse/utils/streamReadiness.ts | 71 +++++- package.json | 1 + scripts/build/sync-env.mjs | 3 + .../ProviderLimits/QuotaCutoffModal.tsx | 31 ++- .../components/ProviderLimits/i18nFallback.ts | 26 ++ .../usage/components/ProviderLimits/index.tsx | 35 ++- src/app/docs/[slug]/page.tsx | 11 +- src/app/docs/lib/docs-auto-generated.ts | 203 ++++++++++++--- src/i18n/messages/en.json | 13 +- src/shared/utils/runtimeTimeouts.ts | 2 +- tests/unit/glm-executor.test.ts | 25 ++ tests/unit/next-config.test.ts | 38 ++- tests/unit/runtime-timeouts.test.ts | 2 +- tests/unit/stream-readiness.test.ts | 236 +++++++++++++++++- 18 files changed, 655 insertions(+), 79 deletions(-) create mode 100644 scripts/build/sync-env.mjs create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/i18nFallback.ts diff --git a/bin/cli/runtime/sqliteRuntime.mjs b/bin/cli/runtime/sqliteRuntime.mjs index 640a0988d7..19dd790764 100644 --- a/bin/cli/runtime/sqliteRuntime.mjs +++ b/bin/cli/runtime/sqliteRuntime.mjs @@ -1,7 +1,8 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { homedir } from "node:os"; import { execSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; import { validateBinaryMagic, platformBinaryLabel } from "./magicBytes.mjs"; const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); @@ -73,7 +74,9 @@ async function tryLoadBundled() { } async function tryLoadRuntimeInstalled() { - const pkgRoot = join(RUNTIME_DIR, "node_modules", "better-sqlite3"); + const runtimeNodeModules = resolve(RUNTIME_DIR, "node_modules"); + const pkgRoot = resolve(runtimeNodeModules, "better-sqlite3"); + if (!pkgRoot.startsWith(`${runtimeNodeModules}/`)) return null; if (!existsSync(join(pkgRoot, "package.json"))) return null; const buildDir = join(pkgRoot, "build", "Release"); @@ -92,7 +95,7 @@ async function tryLoadRuntimeInstalled() { } try { - const mod = await import(pkgRoot); + const mod = await import(/* webpackIgnore: true */ pathToFileURL(pkgRoot).href); return { kind: "better-sqlite3", Database: mod.default ?? mod }; } catch { return null; diff --git a/next.config.mjs b/next.config.mjs index bab8b391b7..96024ac858 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -51,6 +51,16 @@ const securityHeaders = [ }, ]; +function isNextIntlExtractorDynamicImportWarning(warning) { + const message = typeof warning === "string" ? warning : warning?.message || ""; + const resource = warning?.module?.resource || warning?.file || ""; + const target = "next-intl/dist/esm/production/extractor/format/index.js"; + return ( + resource.includes(target) && + (message.includes("import(t)") || message.includes("dependency is an expression")) + ); +} + /** @type {import('next').NextConfig} */ const nextConfig = { distDir, @@ -140,6 +150,13 @@ const nextConfig = { // TODO: Re-enable after fixing all sub-component useTranslations scope issues ignoreBuildErrors: true, }, + webpack(config) { + config.ignoreWarnings = [ + ...(config.ignoreWarnings || []), + isNextIntlExtractorDynamicImportWarning, + ]; + return config; + }, images: { unoptimized: true, }, diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 2a347a23f4..6e78ead8db 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -31,7 +31,7 @@ import { translateRequest } from "../translator/index.ts"; import { FORMATS } from "../translator/formats.ts"; import { createSSETransformStreamWithLogger } from "../utils/stream.ts"; import { ensureStreamReadiness } from "../utils/streamReadiness.ts"; -import { STREAM_IDLE_TIMEOUT_MS } from "../config/constants.ts"; +import { STREAM_READINESS_TIMEOUT_MS } from "../config/constants.ts"; type JsonRecord = Record; type GlmExecuteResult = Awaited> & { @@ -306,7 +306,7 @@ export class GlmExecutor extends DefaultExecutor { if (input.stream && response.ok) { const readiness = await ensureStreamReadiness(response, { - timeoutMs: STREAM_IDLE_TIMEOUT_MS, + timeoutMs: STREAM_READINESS_TIMEOUT_MS, provider: this.provider, model: input.model, log: input.log, diff --git a/open-sse/lib/deepseek-pow.ts b/open-sse/lib/deepseek-pow.ts index 0b1412d621..35ac919a2a 100644 --- a/open-sse/lib/deepseek-pow.ts +++ b/open-sse/lib/deepseek-pow.ts @@ -3,11 +3,6 @@ // so we use the verified extracted module. import { createRequire } from "node:module"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); // Load the exact solver extracted from DeepSeek's worker chunk. // Lazy-loaded inside the function so the standalone Next build can collect @@ -16,7 +11,7 @@ const require = createRequire(import.meta.url); let _U: any | undefined; function loadU(): any { if (_U === undefined) { - _U = require(join(__dirname, "deepseek-pow-solver.cjs")).U; + _U = require("./deepseek-pow-solver.cjs").U; } return _U; } diff --git a/open-sse/utils/streamReadiness.ts b/open-sse/utils/streamReadiness.ts index ef6cb71918..3b73024921 100644 --- a/open-sse/utils/streamReadiness.ts +++ b/open-sse/utils/streamReadiness.ts @@ -66,16 +66,81 @@ function hasUsefulJsonPayload(payload: unknown): boolean { return hasUsefulValue(payload); } +function hasOpenAIResponseLifecyclePayload( + payload: Record, + type: string +): boolean { + if (type === "response.created" || type === "response.in_progress") { + const response = payload.response; + if (!isRecord(response)) return false; + + return ( + hasNonEmptyString(response.id) || + hasNonEmptyString(response.object) || + hasNonEmptyString(response.status) || + typeof response.created_at === "number" + ); + } + + if (type === "response.output_item.added") { + const item = payload.item; + if (!isRecord(item)) return false; + + return ( + hasNonEmptyString(item.id) || + hasNonEmptyString(item.type) || + hasNonEmptyString(item.status) || + Array.isArray(item.content) || + isRecord(item.content) + ); + } + + return false; +} + +function hasChatCompletionToolCallStart(value: unknown): boolean { + const hasToolCallId = (item: unknown) => isRecord(item) && hasNonEmptyString(item.id); + if (Array.isArray(value)) return value.some(hasToolCallId); + return hasToolCallId(value); +} + +function hasChatCompletionFunctionCallStart(value: unknown): boolean { + return isRecord(value) && hasNonEmptyString(value.name); +} + +function hasChatCompletionChunkStartPayload(payload: Record): boolean { + if (payload.object !== "chat.completion.chunk" && payload.type !== "chat.completion.chunk") { + return false; + } + + const choices = payload.choices; + if (!Array.isArray(choices) || choices.length === 0) return false; + + return choices.some((choice) => { + if (!isRecord(choice)) return false; + const delta = choice.delta; + if (!isRecord(delta)) return false; + + return ( + hasNonEmptyString(delta.role) || + hasChatCompletionToolCallStart(delta.tool_calls) || + hasChatCompletionFunctionCallStart(delta.function_call) + ); + }); +} + function hasAcceptedStreamStartPayload(payload: unknown, eventType = ""): boolean { if (!isRecord(payload)) return false; // Anthropic/Claude streams can legitimately start with lifecycle frames and - // then spend a long time thinking before the first text/tool delta arrives. - // Treating the start frame as readiness prevents false 504s while ping-only - // zombie streams still fail below. + // OpenAI Responses streams can do the same before the first text/tool delta + // arrives. Treating structurally valid lifecycle frames as readiness prevents + // false 504s while ping-only/generic-empty zombie streams still fail below. const type = typeof payload.type === "string" ? payload.type : eventType; if (type === "message_start" && isRecord(payload.message)) return true; if (type === "content_block_start" && isRecord(payload.content_block)) return true; + if (hasOpenAIResponseLifecyclePayload(payload, type)) return true; + if (hasChatCompletionChunkStartPayload(payload)) return true; return false; } diff --git a/package.json b/package.json index 8979f11895..21c8004997 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "bin/cli/runtime/", "scripts/postinstall.mjs", "scripts/build/postinstallSupport.mjs", + "scripts/build/sync-env.mjs", "scripts/dev/responses-ws-proxy.mjs", "scripts/check/check-supported-node-runtime.ts", "scripts/dev/sync-env.mjs", diff --git a/scripts/build/sync-env.mjs b/scripts/build/sync-env.mjs new file mode 100644 index 0000000000..f5ee60a42e --- /dev/null +++ b/scripts/build/sync-env.mjs @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +export { getEnvSyncPlan, parseEnvFile, syncEnv } from "../dev/sync-env.mjs"; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal.tsx index 075a2b8788..f075816261 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCutoffModal.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import Modal from "@/shared/components/Modal"; import Button from "@/shared/components/Button"; +import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; export interface QuotaCutoffModalWindow { /** Stable key — must match the quota name surfaced by the usage fetcher. */ @@ -52,6 +53,8 @@ export default function QuotaCutoffModal({ onSave, }: QuotaCutoffModalProps) { const t = useTranslations("usage"); + const tr = (key: string, fallback: string, values?: UsageTranslationValues) => + translateUsageOrFallback(t, key, fallback, values); // Local draft: string per window so empty-string means "inherit". const [drafts, setDrafts] = useState>({}); const [saving, setSaving] = useState(false); @@ -94,7 +97,7 @@ export default function QuotaCutoffModal({ const handleSave = async () => { const patch = buildPatch(); if (patch === "invalid") { - setError(t("quotaThresholdInvalid")); + setError(tr("quotaThresholdInvalid", "Enter a whole number from 0 to 100.")); return; } if (Object.keys(patch).length === 0) { @@ -133,28 +136,38 @@ export default function QuotaCutoffModal({ {hasAnyOverride && ( )} } > -

{t("quotaCutoffsExplainer")}

+

+ {tr( + "quotaCutoffsExplainer", + "Override the minimum remaining quota percentage where this account stops being selected for each quota window. Leave blank to inherit the provider default." + )} +

{windows.length === 0 && ( -
{t("quotaCutoffsNoWindows")}
+
+ {tr("quotaCutoffsNoWindows", "No quota windows are available for this account yet.")} +
)} {windows.map((w) => { const persisted = current?.[w.key]; @@ -167,7 +180,9 @@ export default function QuotaCutoffModal({
{w.displayName}
- {t("quotaCutoffsDefaultHint", { default: resolvedDefault })} + {tr("quotaCutoffsDefaultHint", `Default min remaining: ${resolvedDefault}%`, { + default: resolvedDefault, + })}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/i18nFallback.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/i18nFallback.ts new file mode 100644 index 0000000000..296377fd2b --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/i18nFallback.ts @@ -0,0 +1,26 @@ +export type UsageTranslationValues = Record; + +export type UsageTranslator = { + (key: string, values?: UsageTranslationValues): string; + has?: (key: string) => boolean; +}; + +export function translateUsageOrFallback( + t: UsageTranslator, + key: string, + fallback: string, + values?: UsageTranslationValues +): string { + try { + if (typeof t.has === "function" && !t.has(key)) { + return fallback; + } + const translated = values ? t(key, values) : t(key); + if (!translated || translated === key || translated === `usage.${key}`) { + return fallback; + } + return translated; + } catch { + return fallback; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index 4df3ef897d..be10d68fc0 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -19,6 +19,7 @@ import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; import ProviderIcon from "@/shared/components/ProviderIcon"; import QuotaCutoffModal from "./QuotaCutoffModal"; +import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; const LS_GROUP_BY = "omniroute:limits:groupBy"; const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups"; @@ -26,6 +27,7 @@ const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups"; const MIN_FETCH_INTERVAL_MS = 30000; // Debounce per-connection fetches const QUOTA_BAR_GREEN_THRESHOLD = 50; const QUOTA_BAR_YELLOW_THRESHOLD = 20; +const LIMITS_GRID_TEMPLATE_COLUMNS = "minmax(220px,260px) minmax(240px,1fr) 104px 76px 56px"; // Provider display config const PROVIDER_CONFIG = { @@ -113,6 +115,11 @@ function formatCountdown(resetAt) { export default function ProviderLimits() { const t = useTranslations("usage"); + const tr = useCallback( + (key: string, fallback: string, values?: UsageTranslationValues) => + translateUsageOrFallback(t, key, fallback, values), + [t] + ); const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible); const [connections, setConnections] = useState([]); const [quotaData, setQuotaData] = useState({}); @@ -597,13 +604,19 @@ export default function ProviderLimits() { {/* Table header */}
{t("account")}
{t("modelQuotas")}
{t("lastUsed")}
-
- {t("quotaThresholdLabel")} +
+ {tr("quotaThresholdLabel", "Min left")}
{t("actions")}
@@ -627,7 +640,7 @@ export default function ProviderLimits() { className="items-center px-4 py-3.5 transition-[background] duration-150 hover:bg-black/[0.03] dark:hover:bg-white/[0.02]" style={{ display: "grid", - gridTemplateColumns: "280px 1fr 128px 96px 48px", + gridTemplateColumns: LIMITS_GRID_TEMPLATE_COLUMNS, borderBottom: !isLast ? "1px solid var(--color-border)" : "none", }} > @@ -838,7 +851,7 @@ export default function ProviderLimits() { ); const connectionHasWindows = connectionWindows.length > 0; // Summary: up to 2 entries with short labels; "+N" for the rest. - let label: string = t("quotaCutoffsButtonDefault"); + let label: string = tr("quotaCutoffsButtonDefault", "Default"); if (hasOverrides && overrides) { const entries = Object.entries(overrides); const visible = entries @@ -857,10 +870,16 @@ export default function ProviderLimits() { disabled={!connectionHasWindows} title={ connectionHasWindows - ? t("quotaCutoffsButtonHelp") - : t("quotaCutoffsButtonDisabled") + ? tr( + "quotaCutoffsButtonHelp", + "Edit minimum remaining quota cutoffs for this account." + ) + : tr( + "quotaCutoffsButtonDisabled", + "No quota windows are available for this account yet." + ) } - className={`px-2 py-1 rounded-md border text-[11px] font-medium tabular-nums transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${ + className={`block w-full max-w-[70px] truncate px-1.5 py-1 rounded-md border text-[11px] font-medium tabular-nums transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${ hasOverrides ? "border-primary/40 text-primary bg-primary/5" : "border-border text-text-muted hover:bg-black/[0.04] dark:hover:bg-white/[0.04]" diff --git a/src/app/docs/[slug]/page.tsx b/src/app/docs/[slug]/page.tsx index 44499f940a..d70daa0217 100644 --- a/src/app/docs/[slug]/page.tsx +++ b/src/app/docs/[slug]/page.tsx @@ -8,7 +8,7 @@ import matter from "gray-matter"; import { marked } from "marked"; import DOMPurify from "isomorphic-dompurify"; import { Metadata } from "next"; -import { getLocale } from "next-intl/server"; +import { DEFAULT_LOCALE } from "@/i18n/config"; import { DocCodeBlocks } from "../components/DocCodeBlocks"; import { FeedbackWidget } from "../components/FeedbackWidget"; import { DocsPageAnalytics } from "../components/DocsPageAnalytics"; @@ -167,13 +167,14 @@ export default async function DocPage({ params }: { params: Promise<{ slug: stri let mermaidCharts: string[] = []; try { - // Resolve the current request locale via next-intl. When it isn't `en` - // and a translated copy exists under `docs/i18n//docs/`, - // prefer the translated file. Otherwise fall back to the English source. + // Static docs prerender must not read request cookies/headers. Use the + // configured default locale only; if that locale has translated docs under + // `docs/i18n//docs/`, prefer them, otherwise fall back to the + // English source. // Path resolution is hardened: the docs index supplies the safe filename // (never user input), and we constrain the resolved path to the docs // root so traversal sequences in a stray fileName cannot escape it. - const locale = await getLocale(); + const locale = DEFAULT_LOCALE; const docsRoot = path.resolve(process.cwd(), "docs"); const englishAbs = path.resolve(docsRoot, item.fileName); if (!englishAbs.startsWith(docsRoot + path.sep) && englishAbs !== docsRoot) { diff --git a/src/app/docs/lib/docs-auto-generated.ts b/src/app/docs/lib/docs-auto-generated.ts index e6da757458..09f3e36606 100644 --- a/src/app/docs/lib/docs-auto-generated.ts +++ b/src/app/docs/lib/docs-auto-generated.ts @@ -170,6 +170,11 @@ export const autoNavSections: AutoGenNavSection[] = [ title: "Memory System", fileName: "frameworks/MEMORY.md", }, + { + slug: "opencode", + title: "OpenCode Integration", + fileName: "frameworks/OPENCODE.md", + }, { slug: "skills", title: "Skills Framework", @@ -190,11 +195,6 @@ export const autoNavSections: AutoGenNavSection[] = [ title: "OmniRoute Auto-Combo Engine", fileName: "routing/AUTO-COMBO.md", }, - { - slug: "cli-tools", - title: "CLI Tools Setup Guide", - fileName: "routing/CLI-TOOLS.md", - }, { slug: "reasoning-replay", title: "Reasoning Replay Cache", @@ -205,16 +205,41 @@ export const autoNavSections: AutoGenNavSection[] = [ { title: "Security", items: [ + { + slug: "cli-token", + title: "CLI Machine-ID Token", + fileName: "security/CLI_TOKEN.md", + }, + { + slug: "cli-token-auth", + title: "CLI Machine-ID Token Authentication", + fileName: "security/CLI_TOKEN_AUTH.md", + }, { slug: "compliance", title: "Compliance & Audit", fileName: "security/COMPLIANCE.md", }, + { + slug: "error-sanitization", + title: "Error Message Sanitization", + fileName: "security/ERROR_SANITIZATION.md", + }, { slug: "guardrails", title: "Guardrails", fileName: "security/GUARDRAILS.md", }, + { + slug: "public-creds", + title: "Public Credentials Handling", + fileName: "security/PUBLIC_CREDS.md", + }, + { + slug: "route-guard-tiers", + title: "Route Guard Tiers", + fileName: "security/ROUTE_GUARD_TIERS.md", + }, { slug: "stealth-guide", title: "Stealth Guide", @@ -275,6 +300,11 @@ export const autoNavSections: AutoGenNavSection[] = [ title: "Release Checklist", fileName: "ops/RELEASE_CHECKLIST.md", }, + { + slug: "sqlite-runtime", + title: "SQLite Runtime Resolution", + fileName: "ops/SQLITE_RUNTIME.md", + }, { slug: "tunnels-guide", title: "Tunnels Guide", @@ -784,8 +814,8 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Advanced Tools (11) — Phase 2", "Cache Tools (2)", "Compression Tools (5)", + "MCP Accessibility Tree Filter (v3.8.0)", "1Proxy Tools (3)", - "Memory Tools (3)", ], }, { @@ -808,6 +838,24 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Settings (settings.ts)", ], }, + { + slug: "opencode", + title: "OpenCode Integration", + fileName: "frameworks/OPENCODE.md", + section: "Frameworks", + content: + "Status: Generally available. Audience: Operators wiring OpenCode to an OmniRoute deployment. Source of truth (config schema): src/shared/services/opencodeConfig.ts Source of truth (npm package): @omniroute/opencode-provider/ (publishable workspace) OpenCode is an agentic CLI/desktop AI client. It re", + headings: [ + "Path 1 — CLI generator (no npm install)", + "Path 2 — npm package @omniroute/opencode-provider", + "What the runtime actually does", + "Model catalog defaults", + "URL normalisation", + "Authentication modes", + "Troubleshooting", + "See also", + ], + }, { slug: "skills", title: "Skills Framework", @@ -868,26 +916,6 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Task Fitness", ], }, - { - slug: "cli-tools", - title: "CLI Tools Setup Guide", - fileName: "routing/CLI-TOOLS.md", - section: "Routing", - content: - "This guide explains how to install and configure all supported AI coding CLI tools to use OmniRoute as the unified backend, giving you centralized key management, cost tracking, model switching, and request logging across every tool. The dashboard cards in /dashboard/cli-tools are generated from src", - headings: [ - "How It Works", - "Supported Tools (Dashboard Source of Truth)", - "CLI fingerprint sync (Agents + Settings)", - "Step 1 — Get an OmniRoute API Key", - "Step 2 — Install CLI Tools", - "Step 3 — Set Global Environment Variables", - "Step 4 — Configure Each Tool", - "Claude Code", - "OpenAI Codex", - "OpenCode", - ], - }, { slug: "reasoning-replay", title: "Reasoning Replay Cache", @@ -906,6 +934,38 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "See Also", ], }, + { + slug: "cli-token", + title: "CLI Machine-ID Token", + fileName: "security/CLI_TOKEN.md", + section: "Security", + content: + "OmniRoute CLI commands authenticate against the local management API using a HMAC-SHA256(machine-id, salt) token sent via the x-omniroute-cli-token request header. This allows CLI subcommands (omniroute status, omniroute providers, etc.) to call management endpoints without requiring the user to sup", + headings: [ + "Overview", + "How it works", + "Security properties", + "Salt rotation", + "Files", + "See also", + ], + }, + { + slug: "cli-token-auth", + title: "CLI Machine-ID Token Authentication", + fileName: "security/CLI_TOKEN_AUTH.md", + section: "Security", + content: + "OmniRoute's CLI uses a machine-derived token to authenticate to the local server without requiring an explicit API key. This enables zero-config local use while preserving security for remote access. 1. CLI side (bin/cli/utils/cliToken.mjs): computes SHA-256(machineId + salt).hex[0..32] using node-m", + headings: [ + "How it works", + "Threat model", + "Opt-out", + "Audit logging", + "API key precedence", + "Related files", + ], + }, { slug: "compliance", title: "Compliance & Audit", @@ -926,6 +986,26 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Dashboard", ], }, + { + slug: "error-sanitization", + title: "Error Message Sanitization", + fileName: "security/ERROR_SANITIZATION.md", + section: "Security", + content: + "Source of truth: open-sse/utils/error.ts — sanitizeErrorMessage, buildErrorBody, createErrorResult Tests: tests/unit/error-message-sanitization.test.ts Last updated: 2026-05-14 — v3.8.0 Audience: Any engineer touching error responses (HTTP routes, SSE streams, executors, MCP handlers). Status: MANDA", + headings: [ + "Why this exists", + "The mandatory pattern", + "1. Building an error response (HTTP / API routes)", + "2. Custom error envelopes (rare)", + "3. Logging vs. responding", + "4. Forbidden patterns", + "Coverage in CI", + "Related controls", + "Known CodeQL limitation: custom sanitizers not recognized", + "References", + ], + }, { slug: "guardrails", title: "Guardrails", @@ -946,6 +1026,43 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Custom Guardrails", ], }, + { + slug: "public-creds", + title: "Public Credentials Handling", + fileName: "security/PUBLIC_CREDS.md", + section: "Security", + content: + "Source of truth: open-sse/utils/publicCreds.ts Tests: tests/unit/publicCreds.test.ts Last updated: 2026-05-14 — v3.8.0 Audience: Engineers integrating providers that ship public OAuth clientid / clientsecret / Firebase Web API keys in their public CLIs. Status: MANDATORY for all new code that embeds", + headings: [ + "Why this exists", + "The mandatory pattern", + "1. Adding a new public credential", + "2. Consumers", + "3. Forbidden patterns", + "Related controls", + "When NOT to use this helper", + "References", + ], + }, + { + slug: "route-guard-tiers", + title: "Route Guard Tiers", + fileName: "security/ROUTE_GUARD_TIERS.md", + section: "Security", + content: + "All OmniRoute management API routes are classified into one of three protection tiers. Classification is static, defined in src/server/authz/routeGuard.ts, and evaluated unconditionally on every request before any auth logic runs. Enforced by: isLocalOnlyPath(path) → loopback host check Bypass: None", + headings: [ + "Overview", + "Tiers", + "Tier 1 — LOCAL_ONLY", + "Tier 2 — ALWAYS_PROTECTED", + "Tier 3 — MANAGEMENT (default)", + "Evaluation order", + "Adding a new spawn-capable route", + "Files", + "See also", + ], + }, { slug: "stealth-guide", title: "Stealth Guide", @@ -980,10 +1097,10 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Caveman", "RTK", "Stacked Pipelines", - "Compression Combos", - "API Surface", - "MCP Tools", - "Validation", + "MCP Accessibility Tree Filter", + "What it does", + "Engine location", + "Configuration", ], }, { @@ -1019,6 +1136,9 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "Config Shape", "Adding a Language Pack", "API", + "SHARED_BOUNDARIES (v3.8.0)", + "Why this matters", + "Customizing preservePatterns", "Operational Notes", ], }, @@ -1136,6 +1256,21 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ "i18n", ], }, + { + slug: "sqlite-runtime", + title: "SQLite Runtime Resolution", + fileName: "ops/SQLITE_RUNTIME.md", + section: "Ops", + content: + "OmniRoute resolves its SQLite driver at startup through a 5-step fallback chain: 1. Bundled better-sqlite3 (via dependencies in package.json) — fastest, native binary, installed by npm install when build tools are present. 2. Runtime-installed better-sqlite3 (in /.omniroute/runtime/) — installed laz", + headings: [ + "Why this complexity?", + "Magic-byte validation", + "Checking the active driver", + "Manual control", + "Reference", + ], + }, { slug: "tunnels-guide", title: "Tunnels Guide", @@ -1213,13 +1348,18 @@ export const autoAllSlugs: string[] = [ "evals", "mcp-server", "memory", + "opencode", "skills", "webhooks", "auto-combo", - "cli-tools", "reasoning-replay", + "cli-token", + "cli-token-auth", "compliance", + "error-sanitization", "guardrails", + "public-creds", + "route-guard-tiers", "stealth-guide", "compression-engines", "compression-guide", @@ -1230,6 +1370,7 @@ export const autoAllSlugs: string[] = [ "fly-io-deployment-guide", "proxy-guide", "release-checklist", + "sqlite-runtime", "tunnels-guide", "vm-deployment-guide", ]; diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1ae9bbfd8a..ffa72214cb 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4350,7 +4350,18 @@ "suiteBuilderDescriptionLabel": "Description", "targetComparisonHint": "Side-by-side comparison of evaluation results between targets.", "compareCompletedWithScore": "Comparison completed — {score}% pass rate", - "staleQuotaTooltip": "Last refresh failed — showing cached data" + "staleQuotaTooltip": "Last refresh failed — showing cached data", + "quotaThresholdLabel": "Min left", + "quotaCutoffsColumnHelp": "Stop requests when remaining quota falls to this percentage or below.", + "quotaCutoffsButtonDefault": "Default", + "quotaCutoffsButtonHelp": "Edit minimum remaining quota cutoffs for this account.", + "quotaCutoffsButtonDisabled": "No quota windows are available for this account yet.", + "quotaCutoffsTitle": "Quota cutoffs for {name} ({provider})", + "quotaCutoffsExplainer": "Override the minimum remaining quota percentage where this account stops being selected for each quota window. Leave blank to inherit the provider default.", + "quotaCutoffsDefaultHint": "Default min remaining: {default}%", + "quotaCutoffsResetAll": "Reset all", + "quotaCutoffsNoWindows": "No quota windows are available for this account yet.", + "quotaThresholdInvalid": "Enter a whole number from 0 to 100." }, "modals": { "waitingAuth": "Waiting for Authorization", diff --git a/src/shared/utils/runtimeTimeouts.ts b/src/shared/utils/runtimeTimeouts.ts index 67ff3e0e39..df4e11150d 100644 --- a/src/shared/utils/runtimeTimeouts.ts +++ b/src/shared/utils/runtimeTimeouts.ts @@ -9,7 +9,7 @@ type ReadTimeoutOptions = { export const DEFAULT_FETCH_TIMEOUT_MS = 600_000; export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 600_000; export const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 15_000; -export const DEFAULT_STREAM_READINESS_TIMEOUT_MS = 30_000; +export const DEFAULT_STREAM_READINESS_TIMEOUT_MS = 80_000; export const DEFAULT_FETCH_CONNECT_TIMEOUT_MS = 30_000; export const DEFAULT_FETCH_KEEPALIVE_TIMEOUT_MS = 4_000; export const DEFAULT_API_BRIDGE_PROXY_TIMEOUT_MS = 600_000; diff --git a/tests/unit/glm-executor.test.ts b/tests/unit/glm-executor.test.ts index bc5777aa4b..6456cf3ba9 100644 --- a/tests/unit/glm-executor.test.ts +++ b/tests/unit/glm-executor.test.ts @@ -378,6 +378,7 @@ test("GlmExecutor sends OpenAI coding payload first and enables streaming tool c assert.equal(captured?.headers["anthropic-version"], undefined); assert.equal(captured?.body.tool_stream, true); assert.equal(captured?.body.tools[0].function.name, "get_weather"); + assert.match(await result.response.text(), /chatcmpl-glm/); } finally { globalThis.fetch = originalFetch; } @@ -484,6 +485,30 @@ test("GlmExecutor falls back when primary stream ends before useful content", as } }); +test("GlmExecutor uses readiness timeout for OpenAI-compatible stream handoff", async () => { + const executor = new GlmExecutor("glm"); + const originalFetch = globalThis.fetch; + + globalThis.fetch = async () => makeSseResponse(["event: ping", "data: {}"]); + + try { + const result = await executor.execute({ + model: "glm-5.1", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { + apiKey: "glm-key", + providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" }, + }, + }); + + assert.equal(result.response.status, 502); + assert.match(await result.response.text(), /STREAM_EARLY_EOF/); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("GlmExecutor preserves non-OK streaming upstream status before readiness", async () => { const executor = new GlmExecutor("glm"); const originalFetch = globalThis.fetch; diff --git a/tests/unit/next-config.test.ts b/tests/unit/next-config.test.ts index c303e9562d..82aed99ef6 100644 --- a/tests/unit/next-config.test.ts +++ b/tests/unit/next-config.test.ts @@ -90,12 +90,13 @@ test("next config declares Turbopack aliases, runtime assets and server external } }); -test("next-intl webpack hook preserves caller webpack config without legacy fallbacks", async () => { +test("next-intl webpack hook preserves caller config and filters known extractor warnings", async () => { const { default: nextConfig } = await loadNextConfig("webpack-pass-through"); - const config = { + const config: any = { context: process.cwd(), plugins: [], externals: [], + ignoreWarnings: [], resolve: { fallback: { http: true } }, }; @@ -103,6 +104,8 @@ test("next-intl webpack hook preserves caller webpack config without legacy fall isServer: false, webpack: { IgnorePlugin: class { + options: any; + constructor(options) { this.options = options; } @@ -113,4 +116,35 @@ test("next-intl webpack hook preserves caller webpack config without legacy fall assert.deepEqual(config.plugins, []); assert.deepEqual(config.externals, []); assert.deepEqual(config.resolve.fallback, { http: true }); + assert.equal(config.ignoreWarnings.length, 1); + assert.equal( + config.ignoreWarnings[0]({ + message: + "Parsing of /repo/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.", + module: { + resource: "/repo/node_modules/next-intl/dist/esm/production/extractor/format/index.js", + }, + }), + true + ); + assert.equal( + config.ignoreWarnings[0]({ + message: + "Parsing of /repo/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.", + }), + false + ); + assert.equal( + config.ignoreWarnings[0]({ + message: "Critical dependency: the request of a dependency is an expression", + module: { + resource: "/repo/node_modules/next-intl/dist/esm/production/extractor/format/index.js", + }, + }), + true + ); + assert.equal( + config.ignoreWarnings[0]({ message: "Critical dependency: request is expression" }), + false + ); }); diff --git a/tests/unit/runtime-timeouts.test.ts b/tests/unit/runtime-timeouts.test.ts index ec7927fe79..a99a7246f3 100644 --- a/tests/unit/runtime-timeouts.test.ts +++ b/tests/unit/runtime-timeouts.test.ts @@ -13,7 +13,7 @@ test("upstream timeout config derives hidden fetch timeouts from FETCH_TIMEOUT_M fetchTimeoutMs: 600000, streamIdleTimeoutMs: 600000, sseHeartbeatIntervalMs: 15000, - streamReadinessTimeoutMs: 30000, + streamReadinessTimeoutMs: 80000, fetchHeadersTimeoutMs: 600000, fetchBodyTimeoutMs: 600000, fetchConnectTimeoutMs: 30000, diff --git a/tests/unit/stream-readiness.test.ts b/tests/unit/stream-readiness.test.ts index 15eb22654d..f322bd1516 100644 --- a/tests/unit/stream-readiness.test.ts +++ b/tests/unit/stream-readiness.test.ts @@ -61,6 +61,83 @@ function delayedClaudeStartStream(): ReadableStream { }); } +function delayedOpenAIResponsesStartStream(): ReadableStream { + return new ReadableStream({ + async start(controller) { + controller.enqueue( + encoder.encode( + [ + "event: response.created", + `data: ${JSON.stringify({ + type: "response.created", + response: { + id: "resp_1", + object: "response", + created_at: 1_735_000_000, + status: "in_progress", + }, + })}`, + "", + ].join("\n") + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 30)); + controller.enqueue( + encoder.encode( + [ + "event: response.output_text.delta", + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "slow hello" })}`, + "", + ].join("\n") + ) + ); + controller.close(); + }, + }); +} + +function delayedChatCompletionStartStream(): ReadableStream { + return new ReadableStream({ + async start(controller) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + id: "chatcmpl-glm", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant" } }], + })}\n\n` + ) + ); + + await new Promise((resolve) => setTimeout(resolve, 30)); + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + id: "chatcmpl-glm", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { content: "slow chat hello" } }], + })}\n\n` + ) + ); + controller.close(); + }, + }); +} + +function zombieReadinessStream(): ReadableStream { + return new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(": keepalive\n\n")); + controller.enqueue(encoder.encode("event: ping\ndata: {}\n\n")); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({})}\n\n`)); + await new Promise((resolve) => setTimeout(resolve, 30)); + controller.enqueue(encoder.encode(": still-alive\n\n")); + }, + cancel() {}, + }); +} + test("hasUsefulStreamContent ignores keepalives and lifecycle-only chunks", () => { assert.equal(hasUsefulStreamContent(": keepalive\n\n"), false); assert.equal(hasUsefulStreamContent("event: ping\ndata: {}\n\n"), false); @@ -166,10 +243,122 @@ test("hasStreamReadinessSignal accepts Claude stream start events", () => { ); }); +test("hasStreamReadinessSignal accepts valid OpenAI Responses lifecycle events", () => { + assert.equal(hasStreamReadinessSignal(`data: ${JSON.stringify({})}\n\n`), false); + assert.equal( + hasStreamReadinessSignal(`data: ${JSON.stringify({ type: "response.created" })}\n\n`), + false + ); + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ + type: "response.created", + response: { + id: "resp_1", + object: "response", + created_at: 1_735_000_000, + status: "in_progress", + }, + })}\n\n` + ), + true + ); + assert.equal( + hasStreamReadinessSignal( + `event: response.in_progress\ndata: ${JSON.stringify({ + response: { id: "resp_1", status: "in_progress" }, + })}\n\n` + ), + true + ); + assert.equal( + hasStreamReadinessSignal( + `event: response.output_item.added\ndata: ${JSON.stringify({ + item: { id: "msg_1", type: "message", content: [{ type: "output_text", text: "" }] }, + })}\n\n` + ), + true + ); +}); + +test("hasStreamReadinessSignal accepts structural chat completion chunk starts", () => { + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ + id: "chatcmpl-glm", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant" } }], + })}\n\n` + ), + true + ); + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ + id: "chatcmpl-glm", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: "call_1" }] } }], + })}\n\n` + ), + true + ); + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ + id: "chatcmpl-glm", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { function_call: { name: "read_file" } } }], + })}\n\n` + ), + true + ); + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ object: "chat.completion.chunk", choices: [] })}\n\n` + ), + false + ); + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {} }], + })}\n\n` + ), + false + ); + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { tool_calls: [{ index: 0 }] } }], + })}\n\n` + ), + false + ); + assert.equal( + hasStreamReadinessSignal( + `data: ${JSON.stringify({ + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { function_call: {} } }], + })}\n\n` + ), + false + ); +}); + test("ensureStreamReadiness preserves buffered chunks when stream starts", async () => { const response = new Response( streamFromChunks([ - `data: ${JSON.stringify({ type: "response.created" })}\n\n`, + `data: ${JSON.stringify({ + type: "response.created", + response: { + id: "resp_1", + object: "response", + created_at: 1_735_000_000, + status: "in_progress", + }, + })}\n\n`, `data: ${JSON.stringify({ choices: [{ delta: { content: "hello" }, index: 0 }] })}\n\n`, `data: ${JSON.stringify({ choices: [{ delta: { content: " world" }, index: 0 }] })}\n\n`, ]), @@ -201,14 +390,45 @@ test("ensureStreamReadiness hands off long Claude streams after message_start", assert.match(text, /slow hello/); }); +test("ensureStreamReadiness hands off long OpenAI Responses streams after response.created", async () => { + const response = new Response(delayedOpenAIResponsesStartStream(), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + + const result = await ensureStreamReadiness(response, { + timeoutMs: 10, + provider: "openai-compatible-test", + model: "gpt-responses-test", + }); + assert.equal(result.ok, true); + const text = await result.response.text(); + assert.match(text, /response\.created/); + assert.match(text, /slow hello/); +}); + +test("ensureStreamReadiness hands off chat completion streams after role-only start", async () => { + const response = new Response(delayedChatCompletionStartStream(), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); + + const result = await ensureStreamReadiness(response, { + timeoutMs: 10, + provider: "glm", + model: "glm-5.1", + }); + assert.equal(result.ok, true); + const text = await result.response.text(); + assert.match(text, /role/); + assert.match(text, /slow chat hello/); +}); + test("ensureStreamReadiness returns 504 when no useful content arrives before timeout", async () => { - const response = new Response( - streamFromChunks( - [": keepalive\n\n", `data: ${JSON.stringify({ type: "response.created" })}\n\n`], - 20 - ), - { status: 200, headers: { "Content-Type": "text/event-stream" } } - ); + const response = new Response(zombieReadinessStream(), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); const result = await ensureStreamReadiness(response, { timeoutMs: 10 }); assert.equal(result.ok, false);