diff --git a/.env.example b/.env.example index 970bb9f3cf..653b82a1a1 100644 --- a/.env.example +++ b/.env.example @@ -246,6 +246,11 @@ OMNIROUTE_USE_TURBOPACK=1 # hints in production logs. # OMNIROUTE_PROXY_FETCH_DEBUG=true +# Set to "true" or "1" to include client/egress IPs and the account prefix in +# the verbose `[ProxyEgress]` process-log line (src/lib/proxyLogger.ts). Kept +# OFF by default so the process log does not leak IPs or the account prefix. +# PROXY_LOG_INCLUDE_IPS=true + # Set to any non-empty value to emit `[omniroute completion]` diagnostics from # the CLI shell-completion cache paths (read/refresh/write) in # bin/cli/commands/completion.mjs. Off by default — these caches fail silently @@ -370,6 +375,16 @@ ALLOW_API_KEY_REVEAL=false # OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800 # Maximum heavyweight requests simultaneously admitted in one process. Default 1. # OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1 +# Heap-pressure shed ratio (heapUsed/heap_size_limit) for the structural admission gate +# (#10183, #10268): a second concurrent heavyweight request past OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT +# is only shed with a retryable 503 when the heap is ALSO under this much pressure — on a +# healthy heap it is admitted instead. Range (0, 1]. Default 0.75. +# OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO=0.75 +# Bounded extra capacity for the healthy-heap fast path above OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT +# (#10437): once this many concurrent leases are active through the healthy-heap bypass, +# further busy requests fall through to the same bounded-wait/shed path used under real heap +# pressure. 0 disables the bypass entirely. Default 1. +# OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM=1 # Message count that classifies an otherwise small body as heavyweight. Default 200. # OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT=200 # Tool count that classifies an otherwise small body as heavyweight. Default 64. @@ -393,6 +408,12 @@ ALLOW_API_KEY_REVEAL=false # OMNIROUTE_CHAT_VIRTUAL_TTL_MS=60000 # Per-connection virtual admission lanes (#9654): max concurrent sessions (lanes). Default 64. # OMNIROUTE_CHAT_VIRTUAL_MAX_SESSIONS=64 +# Adaptive runtime virtual admission lanes (#9654): master switch for the per-tenant +# adaptive gate (system 2, open-sse/services/admission). NOTE: the TTL/MAX_SESSIONS +# vars above tune the byte-level per-connection lanes (system 1); this switch enables +# the adaptive runtime lanes. Dashboard feature flag of the same name; env wins over +# the dashboard override; restart required. Default: off. +# OMNIROUTE_CHAT_VIRTUAL_LANES=1 # Hard cap (bytes) for a non-streaming upstream response buffered fully into memory # (#5152). Past this the upstream reader is cancelled and the request fails fast @@ -742,6 +763,9 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # CLI_CONTINUE_BIN=cn # CLI_QODER_BIN=qoder # CLI_QWEN_BIN=qwen +# CLI_AIDER_BIN=aider +# CLI_GOOSE_BIN=goose +# CLI_GEMINI_BIN=gemini # CLI_AUGGIE_BIN=auggie # AUGGIE_BIN=auggie @@ -800,6 +824,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode). # OMNIROUTE_CONTEXT= +# Disable the optional OS keychain backend for CLI remote-context credentials. +# When enabled, context tokens stay in config.json with mode 0600 and the CLI +# prints a one-time fallback warning. Useful for deliberate headless/container +# operation; leave unset to use keytar when the native backend is available. +# Used by: bin/cli/contexts.mjs. +# OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED=0 + # Enforce scope-based access control on MCP tool calls. # Used by: open-sse/mcp-server/server.ts — rejects calls outside allowed scopes. # OMNIROUTE_MCP_ENFORCE_SCOPES=false @@ -1253,6 +1284,14 @@ CURSOR_USER_AGENT="Cursor/3.4" # hatches that are referenced in code today. # DEEPSEEK_API_KEY= # NVIDIA_API_KEY= +# Jina Foundation API + Reader fallback when no dashboard jina-ai / jina-reader +# connection exists. Dashboard keys always win (fill-first). +# JINA_AI_API_KEY= +# JINA_API_KEY= +# Gemini / Google AI Studio embeddings fallback when no dashboard gemini +# connection exists. Dashboard keys always win (fill-first). +# GEMINI_API_KEY= +# GOOGLE_API_KEY= # Windsurf / Devin CLI direct API key. # Used by: open-sse/executors/devin-cli.ts — bypasses OAuth when set. @@ -1648,6 +1687,16 @@ APP_LOG_TO_FILE=true # Used by: src/shared/constants/featureFlagDefinitions.ts, src/lib/arenaEloSync.ts # ARENA_ELO_SYNC_ENABLED=true +# How model ids are prefixed in GET /v1/models. "dual" (default) advertises BOTH the +# short alias prefix and the canonical provider prefix for each model (cc/claude-sonnet-4-6 +# AND claude/claude-sonnet-4-6) so client configs that hardcoded either form keep working — +# which roughly doubles the catalog. "alias" emits one id per model; "canonical" emits only +# the full provider-id prefix (and drops providers whose alias is already canonical). +# A client can override per request with GET /v1/models?prefix=alias instead. +# Also configurable from Dashboard > Settings > Feature Flags. +# Used by: src/shared/constants/featureFlagDefinitions.ts, src/app/api/v1/models/catalog.ts +# MODELS_CATALOG_PREFIX_MODE=dual + # Sync interval in seconds. Default: 86400 (24 hours). # ARENA_ELO_SYNC_INTERVAL=86400 @@ -1756,6 +1805,12 @@ APP_LOG_TO_FILE=true # Used by: open-sse/executors/cloudflare-ai.ts # CLOUDFLARE_ACCOUNT_ID= +# ── Cloudflare AI Playground ── +# Full desktop Chrome binary path, used when Playwright's bundled Chromium is +# blocked by the headless fingerprint check. +# Used by: open-sse/executors/cloudflare-playground.ts +# CLOUDFLARE_PLAYGROUND_CHROME_PATH= + # ── Deno Deploy proxy relay (#4643 / 9router#1437) ── # Override the Deno Deploy REST API base used by the proxy-pool relay deployer. # Default: https://api.deno.com/v2 (omit unless mocking). @@ -2796,3 +2851,13 @@ QUOTA_STORE_DRIVER=sqlite # Spokesperson (Faro) base URL for the dashboard chat proxy (/api/conductor/ask). # Used by: src/lib/conductor/faroProxy.ts # CONDUCTOR_SPOKESPERSON_URL=http://127.0.0.1:7920 + +# ═══════════════════════════════════════════════════════════════════════════════ +# QUOTA-AWARE PROVIDER SCHEDULING (opt-in, Phase 2) +# ═══════════════════════════════════════════════════════════════════════════════ +# When enabled, routing skips connections whose configured per-window token +# budget (rateLimitOverrides.tpm) cannot afford the estimated request cost — +# before dispatching — instead of waiting for a 429. Fail-open: connections +# without a configured budget are always considered affordable. Requires the +# provider_quota_state table (migration 148). +# OMNIROUTE_QUOTA_AWARE_ROUTING=0 diff --git a/.gitignore b/.gitignore index b91ffc9f01..a21784f4aa 100644 --- a/.gitignore +++ b/.gitignore @@ -288,3 +288,6 @@ docker-compose.yml.bak # CLI local cache/state .playwright-cli + +# Ad-hoc test sandboxes (never tracked — may contain local DBs) +/.sandbox/ diff --git a/AGENTS.md b/AGENTS.md index 7c6255a318..c22f28dbd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ Repository map and Reference Documentation sections below. ## Project at a Glance -**OmniRoute** — unified AI proxy/router. One endpoint, 341 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 340 LLM providers, auto-fallback. | Layer | Location | Purpose | | ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -56,7 +56,7 @@ Repository map and Reference Documentation sections below. | Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | | Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | | Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (150 migrations) | +| Database | `src/lib/db/` | SQLite domain modules (153 migrations) | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | MCP Server | `open-sse/mcp-server/` | 109 tools (44 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | @@ -433,6 +433,7 @@ For any non-trivial change, read the matching deep-dive first: | Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` | | Tunnels | `docs/ops/TUNNELS_GUIDE.md` | | Electron desktop app | `docs/guides/ELECTRON_GUIDE.md` | +| VS Code Copilot Chat (OmniCopilot extension) | `docs/guides/VSCODE-COPILOT.md` | | Release flow | `docs/ops/RELEASE_CHECKLIST.md` | | Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` | | Quality gates (~80 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` | diff --git a/README.md b/README.md index a5ef67269e..3f14eccce9 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ # 🚀 OmniRoute — The Free AI Gateway -OmniRoute — Never stop coding. Every AI tool → 341 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 341 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. +OmniRoute — Never stop coding. Every AI tool → 340 providers — 90+ free — through one endpoint. Claude Code, Codex, Cursor, Cline, Copilot & Antigravity into FREE Claude / GPT / Gemini with auto-fallback. RTK + Caveman stacked compression saves 15–95% tokens (~89% avg) — never hit limits. 340 AI providers · 90+ free tiers · ~1.51B free tokens/mo · 19 routing strategies · $0 to start. @@ -63,7 +63,7 @@ | | v3.8.49 | **v3.8.50** | `v3.8.51+` | | ------------------------- | :-----: | :---------: | :---------: | -| 🌐 Providers | 290 | **341** | more queued | +| 🌐 Providers | 290 | **340** | more queued | | 🧠 Documented models | 1185 | **1202** | — | | 🖼️ Modality Bridge | — | 🆕 vision | video | | 📡 Radar free catalog | — | 🆕 opt-in | — | @@ -101,7 +101,7 @@ ⚙️ Features 🎯 Combos - 🌐 Providers + 🌐 Providers 🔌 CLI & MCP @@ -210,7 +210,7 @@ curl http://localhost:20128/v1/chat/completions \ -The Promise — One endpoint. 341 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 341 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests). +The Promise — One endpoint. 340 providers. Never stop building — OmniRoute picks the cheapest one that works. Six pillars: Never hit limits (auto-fallback across 340 providers in milliseconds, zero downtime) · Save up to 95% tokens (RTK + Caveman stacked compression cuts 15–95%, ~89% avg on tool-heavy sessions) · $0 to start (90+ free tiers, 56 free forever — no card needed) · Every tool works (33 coding agents through one config) · One endpoint (OpenAI ↔ Claude ↔ Gemini ↔ Responses API at /v1) · Production-grade (circuit breakers, TLS stealth, MCP 109 tools, A2A, memory, guardrails, evals — 25,000+ tests).

@@ -461,7 +461,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 341 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs. +What sets OmniRoute apart — comparison table vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 340 providers, 90+ free providers built-in, 19 routing strategies, 12-engine token compression, built-in MCP server with 109 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA, 43 i18n UI locales, 100% MIT self-hosted. OmniRoute is the only one with the full set; competitors show a mix of checks, partials and crosses. Verified from each project's docs. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -548,7 +548,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute - **🗜️ Compression hardening** — default-on inflation guard, Caveman packs for DE / FR / JA + Chinese (wényán), RTK filters for Gradle & .NET. → [Compression](docs/compression/COMPRESSION_ENGINES.md) - **💸 Honest flat-rate cost** — subscription / coding-plan providers read **$0** in cost analytics; budget, quota & routing keep estimating. → [API Reference](docs/reference/API_REFERENCE.md) - **⚖️ Quota-Share routing** — split a shared account's quota fairly across pooled keys, work-conserving so idle slices are lent out. → [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) -- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute launch` / `launch-codex` are zero-config. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) +- **🤖 One-command CLI/agent setup** — `setup-*` configures 12+ coding tools; `omniroute run` launches 7 CLIs (Claude Code, Codex, Aider, Goose, OpenCode, Qwen Code, Gemini CLI) with zero config written; `omniroute configure` is an interactive provider+model picker with per-context favorites. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) - **🛰️ Remote mode** — drive a remote OmniRoute with scoped tokens (`connect` / `contexts` / `tokens`) + an `antigravity` OAuth helper for VPS installs. → [Remote Mode](docs/guides/REMOTE-MODE.md) - **🧭 Smarter auto-routing** — `auto/:` combos, **Fusion** (model panel + judge), task-aware routing, per-request model / mode / USD-budget overrides. → [Auto-Combo](docs/routing/AUTO-COMBO.md) - **🗜️ Pluggable compression** — 12 composable engines + Compression Studios: LLMLingua-2, two-tier Ultra, omniglyph, per-step fidelity gate, GCF v3.2, drag-reorder editor. → [Compression](docs/compression/COMPRESSION_ENGINES.md) @@ -559,7 +559,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute - **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md) - **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Google Imagen, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md) - **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md) -- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **341-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md) +- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **340-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md) - **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md) - **⚡ Local performance & infra** — one-click local Redis, Cloudflare Workers / Deno Deploy relay deployers, Bifrost & Mux as supervised embedded services. → [Embedded Services](docs/frameworks/EMBEDDED-SERVICES.md) @@ -618,13 +618,35 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
+**Launch any supported CLI through OmniRoute in one command** — no config files written, +credentials injected per process, Qwen/Gemini get a throwaway isolated home: + +```bash +omniroute run claude --model openai/gpt-5.4 # Claude Code +omniroute run codex --model glm/glm-5.2 # OpenAI Codex CLI +omniroute run aider --model glm/glm-5.2 -- --message "reply OK" +omniroute run goose --model glm/glm-5.2 +omniroute run opencode --model glm/glm-5.2 -- run "reply OK" +omniroute run qwen --model glm/glm-5.2 -- -p "reply OK" +omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK" + +# Or pick provider+model interactively and write the tool's own config: +omniroute configure codex # also: claude opencode qwen aider goose cline continue kilo +``` + +Every command honors the active remote context (`omniroute connect `), `--dry-run` +previews the exact env/args without executing, and `--api-key-env NAME` keeps secrets out +of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) + +
+
-## 🌐 341 AI Providers — 90+ Free +## 🌐 340 AI Providers — 90+ Free
-> The most complete catalog of any open-source router: **341 providers**, **90+ with a free tier**, **56 free forever**. +> The most complete catalog of any open-source router: **340 providers**, **90+ with a free tier**, **56 free forever**.
@@ -737,6 +759,8 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl — works the same way on both stores. Source, issues and the publishing runbook live at [diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot). +📖 [VS Code Copilot Chat guide](docs/guides/VSCODE-COPILOT.md) — setup, what the picker shows, dashboard-in-a-tab, troubleshooting +
@@ -1148,7 +1172,7 @@ Métricas de validação: 1002 vídeos rastreados · 7,069,190 visualizações c RuntimeNode.js 22.x / 24.x LTS — >=22.22.2 <23 || >=24.0.0 <27 LanguageTypeScript 6.0 — 100% TypeScript across src/ and open-sse/ (zero any in core since v2.0) FrameworkNext.js 16 + React 19 + Tailwind CSS 4 - Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 117 domain modules, 150 migrations + Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 120 domain modules, 153 migrations MemorySQLite FTS5 full-text + int8-quantized vector embeddings, typed decay SchemasZod 4 — MCP tool I/O validation + API contracts ProtocolsMCP (stdio / HTTP / SSE) + A2A v0.3 (JSON-RPC 2.0 + SSE) diff --git a/bin/cli/api.mjs b/bin/cli/api.mjs index 97dea5e3ea..fff6cf0829 100644 --- a/bin/cli/api.mjs +++ b/bin/cli/api.mjs @@ -1,6 +1,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import { getCliToken, CLI_TOKEN_HEADER } from "./utils/cliToken.mjs"; -import { resolveActiveContext } from "./contexts.mjs"; +import { resolveActiveContext, resolveActiveContextAsync } from "./contexts.mjs"; export const RETRY_DEFAULTS = Object.freeze({ maxAttempts: 3, @@ -77,7 +77,7 @@ export async function buildHeaders(opts) { let auth = explicitKey; if (!auth) { try { - const ctx = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + const ctx = await resolveActiveContextAsync(opts.context ?? process.env.OMNIROUTE_CONTEXT); auth = ctx?.accessToken || ctx?.apiKey || null; } catch { // No context credential available — fall through to the ambient fallback. diff --git a/bin/cli/cli-manifest.mjs b/bin/cli/cli-manifest.mjs new file mode 100644 index 0000000000..fe6098a98a --- /dev/null +++ b/bin/cli/cli-manifest.mjs @@ -0,0 +1,138 @@ +/** + * Canonical executable manifest for the OmniRoute CLI command surfaces. + * + * One entry per canonical target id. `run.mjs`, `configure.mjs` and + * `completion.mjs` derive their target lists, alias resolution and model-flag + * wiring from this table instead of keeping private copies, so a new target + * (or a renamed alias) is declared exactly once. + * + * The server-side runtime catalog (`src/shared/services/cliRuntime.ts`) stays + * the source of truth for binaries, config paths and health checks; the drift + * test `tests/unit/cli/cli-manifest-drift.test.ts` asserts the two worlds and + * every consumer surface stay in sync. + * + * Capability semantics: + * - `run`: launchable through `omniroute run `. + * - `configure`: supported by the `omniroute configure ` picker. + * - `runModel`: how `run` injects `--model` for the target (`null` when the + * model travels via env/provider args instead of a CLI flag). + */ + +export const CLI_TARGET_MANIFEST = Object.freeze({ + claude: Object.freeze({ + description: "Claude Code", + aliases: Object.freeze(["claude-code", "cc", "anthropic"]), + run: true, + configure: true, + runModel: null, // injected via ANTHROPIC_MODEL env by the launcher + }), + codex: Object.freeze({ + description: "OpenAI Codex CLI", + aliases: Object.freeze(["codex-cli", "openai-codex", "openai"]), + run: true, + configure: true, + runModel: null, // injected via -c model_providers.omniroute.* args + }), + aider: Object.freeze({ + description: "Aider", + aliases: Object.freeze([]), + run: true, + configure: true, + runModel: Object.freeze({ flag: "--model", prefix: "openai/" }), + }), + goose: Object.freeze({ + description: "Goose", + aliases: Object.freeze(["goose-cli"]), + run: true, + configure: true, + runModel: null, // injected via GOOSE_MODEL env + }), + opencode: Object.freeze({ + description: "OpenCode", + aliases: Object.freeze(["open-code"]), + run: true, + configure: true, + runModel: Object.freeze({ flag: "--model", prefix: "omniroute/" }), + }), + qwen: Object.freeze({ + description: "Qwen Code", + aliases: Object.freeze(["qwen-code"]), + run: true, + configure: true, + runModel: Object.freeze({ flag: "--model", prefix: "", required: true }), + }), + gemini: Object.freeze({ + // Launch contract verified against @google/gemini-cli 0.50.0: + // GOOGLE_GEMINI_BASE_URL points the SDK at OmniRoute's /v1beta surface, + // GEMINI_API_KEY + isolated GEMINI_CLI_HOME (settings selectedType + // "gemini-api-key") force API-key auth over any stored OAuth session. + description: "Google Gemini CLI", + aliases: Object.freeze(["gemini-cli"]), + run: true, + configure: false, + runModel: Object.freeze({ flag: "--model", prefix: "" }), + }), + cline: Object.freeze({ + description: "Cline", + aliases: Object.freeze([]), + run: false, + configure: true, + runModel: null, + }), + continue: Object.freeze({ + description: "Continue", + aliases: Object.freeze(["cn"]), + run: false, + configure: true, + runModel: null, + }), + kilo: Object.freeze({ + description: "Kilo Code", + aliases: Object.freeze(["kilocode", "kilo-code", "kilo_cli"]), + run: false, + configure: true, + runModel: null, + }), +}); + +/** + * List canonical target ids, optionally filtered by capability + * (`"run"` or `"configure"`). Order follows manifest declaration order. + */ +export function listManifestTargets(capability) { + return Object.entries(CLI_TARGET_MANIFEST) + .filter(([, entry]) => !capability || entry[capability]) + .map(([id]) => id); +} + +/** + * Resolve a user-supplied target (canonical id or alias) to its canonical id. + * Returns `undefined` when the target is unknown or lacks the capability. + */ +export function resolveManifestTarget(rawTarget, capability) { + const normalized = String(rawTarget || "") + .trim() + .toLowerCase(); + if (!normalized) return undefined; + for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) { + if (id === normalized || entry.aliases.includes(normalized)) { + if (capability && !entry[capability]) return undefined; + return id; + } + } + return undefined; +} + +/** Model CLI-flag arguments for a `run` target, derived from the manifest. */ +export function manifestModelArgs(targetId, model) { + if (!model) return []; + const spec = CLI_TARGET_MANIFEST[targetId]?.runModel; + if (!spec) return []; + const value = spec.prefix && !model.startsWith(spec.prefix) ? `${spec.prefix}${model}` : model; + return [spec.flag, value]; +} + +/** Whether a `run` target refuses to launch without an explicit model. */ +export function manifestRequiresModel(targetId) { + return Boolean(CLI_TARGET_MANIFEST[targetId]?.runModel?.required); +} diff --git a/bin/cli/commands/completion.mjs b/bin/cli/commands/completion.mjs index b8c9f89b89..b395e678a2 100644 --- a/bin/cli/commands/completion.mjs +++ b/bin/cli/commands/completion.mjs @@ -4,6 +4,12 @@ import { homedir } from "node:os"; import { t } from "../i18n.mjs"; import { apiFetch } from "../api.mjs"; import { resolveDataDir } from "../data-dir.mjs"; +import { listManifestTargets } from "../cli-manifest.mjs"; + +// Target lists shared with `omniroute run` / `omniroute configure` — always +// derived from the canonical manifest so the completion scripts cannot drift. +const RUN_TARGET_WORDS = listManifestTargets("run").join(" "); +const CONFIGURE_TARGET_WORDS = listManifestTargets("configure").join(" "); const CACHE_TTL_MS = 60 * 60 * 1000; // 1h @@ -129,6 +135,14 @@ _omniroute() { 'completion:Shell completion' 'memory:Manage memory store' 'skills:Manage skills' + 'connect:Connect to a local or remote OmniRoute server' + 'contexts:Manage local and remote server contexts' + 'configure:Configure a supported AI CLI' + 'launch:Launch an AI CLI through OmniRoute' + 'launch-codex:Launch Codex through OmniRoute' + 'run:Run a supported AI CLI through OmniRoute' + 'runtime:Inspect CLI runtime capabilities' + 'repair:Repair native runtime dependencies' ) _arguments -C \\ @@ -153,7 +167,7 @@ _omniroute() { local -a providers providers=($(_omniroute_get_cache providers)) _describe 'provider' providers ;; - *) _arguments '1:subcommand:(list add remove test)' ;; + *) _arguments '1:subcommand:(available list test test-all validate rotate status add import auth remove edit metrics metric)' ;; esac ;; chat|stream) _arguments \\ @@ -165,6 +179,12 @@ _omniroute() { _arguments '1:resource:(combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience)' ;; completion) _arguments '1:subcommand:(zsh bash fish install refresh)' ;; config) _arguments '1:subcommand:(list get set validate contexts)' ;; + contexts) _arguments '1:subcommand:(list add use current show remove rename export import migrate)' ;; + configure) _arguments '1:target:(${CONFIGURE_TARGET_WORDS})' ;; + run) _arguments '1:target:(${RUN_TARGET_WORDS})' ;; + connect) _arguments '1:host:' ;; + launch|launch-codex) _arguments '--remote[Use a remote server]' '--context[Context name]:' '--model[Model ID]:' ;; + runtime) _arguments '1:subcommand:(check repair clean)' ;; *) ;; esac case $state in @@ -208,15 +228,19 @@ _omniroute() { COMPREPLY=() cur="\${COMP_WORDS[COMP_CWORD]}" prev="\${COMP_WORDS[COMP_CWORD-1]}" - cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills run" + cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex run runtime repair" case "\${prev}" in combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;; keys) COMPREPLY=($(compgen -W "add list remove regenerate revoke reveal usage" -- "\${cur}")); return 0 ;; - providers) COMPREPLY=($(compgen -W "available list test test-all" -- "\${cur}")); return 0 ;; + providers) COMPREPLY=($(compgen -W "available list test test-all validate rotate status add import auth remove edit metrics metric" -- "\${cur}")); return 0 ;; config) COMPREPLY=($(compgen -W "list get set validate contexts" -- "\${cur}")); return 0 ;; completion) COMPREPLY=($(compgen -W "zsh bash fish install refresh" -- "\${cur}")); return 0 ;; open) COMPREPLY=($(compgen -W "combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience" -- "\${cur}")); return 0 ;; + contexts) COMPREPLY=($(compgen -W "list add use current show remove rename export import migrate" -- "\${cur}")); return 0 ;; + configure) COMPREPLY=($(compgen -W "${CONFIGURE_TARGET_WORDS}" -- "\${cur}")); return 0 ;; + run) COMPREPLY=($(compgen -W "${RUN_TARGET_WORDS}" -- "\${cur}")); return 0 ;; + runtime) COMPREPLY=($(compgen -W "check repair clean" -- "\${cur}")); return 0 ;; --model) local models models=$(_omniroute_get_cache models) @@ -242,7 +266,7 @@ function generateFishScript() { return `# OmniRoute CLI fish completion (dynamic) complete -c omniroute -f -set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills update test run +set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills connect contexts configure launch launch-codex update test run runtime repair for cmd in $commands complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd @@ -251,10 +275,14 @@ end # Subcommands complete -c omniroute -n '__fish_seen_subcommand_from combo' -a 'list switch create delete show suggest' complete -c omniroute -n '__fish_seen_subcommand_from keys' -a 'add list remove regenerate revoke reveal usage' -complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all' +complete -c omniroute -n '__fish_seen_subcommand_from providers' -a 'available list test test-all validate rotate status add import auth remove edit metrics metric' complete -c omniroute -n '__fish_seen_subcommand_from config' -a 'list get set validate contexts' complete -c omniroute -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish install refresh' complete -c omniroute -n '__fish_seen_subcommand_from open' -a 'combos providers api-manager cli-tools agents settings logs memory skills evals audit cost resilience' +complete -c omniroute -n '__fish_seen_subcommand_from contexts' -a 'list add use current show remove rename export import migrate' +complete -c omniroute -n '__fish_seen_subcommand_from configure' -a '${CONFIGURE_TARGET_WORDS}' +complete -c omniroute -n '__fish_seen_subcommand_from run' -a '${RUN_TARGET_WORDS}' +complete -c omniroute -n '__fish_seen_subcommand_from runtime' -a 'check repair clean' # Dynamic completions from cache (requires python3) function __omniroute_cache_get diff --git a/bin/cli/commands/configure.mjs b/bin/cli/commands/configure.mjs index c84846148f..0021d4350b 100644 --- a/bin/cli/commands/configure.mjs +++ b/bin/cli/commands/configure.mjs @@ -2,9 +2,17 @@ import os from "node:os"; import path from "node:path"; import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs"; import { apiFetch } from "../api.mjs"; +import { loadContexts, resolveActiveContext } from "../contexts.mjs"; import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs"; import { t } from "../i18n.mjs"; import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; +import { + getModelPreferenceState, + loadModelPreferences, + rankPreferredModels, + recordModelPreference, +} from "../model-preferences.mjs"; +import { listManifestTargets, resolveManifestTarget } from "../cli-manifest.mjs"; /** * `omniroute configure ` — interactive provider+model picker that writes a @@ -14,11 +22,80 @@ import { guardHostConfigTarget } from "../utils/config-home-guard.mjs"; * are in remote mode (`omniroute connect ...`) you pick from the remote server's * live models and the profile is written on THIS machine. * - * v1 targets the Codex CLI (writes ~/.codex/.config.toml). The credential - * is referenced by env var (OMNIROUTE_API_KEY) — never written to disk. + * Codex keeps its profile-specific TOML files. Other targets delegate to their + * existing setup-* recipe after the same provider/model selection, so the + * picker remains a read-only orchestration layer and does not duplicate config + * merge logic. */ -const SUPPORTED = ["codex"]; +const SUPPORTED = listManifestTargets("configure"); + +export const SETUP_MODULES = { + claude: { module: "./setup-claude.mjs", exportName: "runSetupClaudeCommand" }, + opencode: { module: "./setup-opencode.mjs", exportName: "runSetupOpencodeCommand" }, + qwen: { module: "./setup-qwen.mjs", exportName: "runSetupQwenCommand" }, + aider: { module: "./setup-aider.mjs", exportName: "runSetupAiderCommand" }, + goose: { module: "./setup-goose.mjs", exportName: "runSetupGooseCommand" }, + cline: { module: "./setup-cline.mjs", exportName: "runSetupClineCommand" }, + continue: { module: "./setup-continue.mjs", exportName: "runSetupContinueCommand" }, + kilo: { module: "./setup-kilo.mjs", exportName: "runSetupKiloCommand" }, +}; + +/** + * Materialize the active server before delegating to a setup recipe. + * + * `apiFetch` knows how to prefer a named context over an ambient + * `OMNIROUTE_API_KEY`, but the older setup modules receive plain options and + * resolve those themselves. Passing the resolved URL/key here keeps the + * picker and the delegated recipe on the same local/remote target, including + * Claude Code which predates context-aware setup resolution. + */ +export function resolveConfigureTargetOptions(opts = {}) { + const resolved = { ...opts }; + const ambientKey = process.env.OMNIROUTE_API_KEY || ""; + const explicitRemote = opts.remote || opts.baseUrl; + let context; + try { + context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT); + } catch { + // A missing/corrupt context file should retain the normal local fallback. + } + + if (!explicitRemote) { + const localDefault = `http://localhost:${opts.port || process.env.PORT || "20128"}`; + const contextBase = String(context?.baseUrl || "").replace(/\/+$/, ""); + if (contextBase && contextBase !== localDefault) { + resolved.remote = contextBase; + } else if (opts.port) { + resolved.remote = localDefault; + } + } else if (!resolved.remote && resolved.baseUrl) { + resolved.remote = resolved.baseUrl; + } + + const contextKey = context?.accessToken || context?.apiKey; + if (contextKey && (!opts.apiKey || opts.apiKey === ambientKey)) { + resolved.apiKey = contextKey; + } + return resolved; +} + +export function listConfigureTargets() { + return [...SUPPORTED]; +} + +export { getModelPreferenceState, rankPreferredModels }; + +function preferenceContextName(opts = {}) { + if (opts.context || process.env.OMNIROUTE_CONTEXT) { + return String(opts.context || process.env.OMNIROUTE_CONTEXT); + } + try { + return String(loadContexts().currentContext || "default"); + } catch { + return "default"; + } +} /** Derive a short, filesystem-safe profile name from a model id. */ export function profileNameFromModel(modelId) { @@ -80,8 +157,15 @@ async function configureCodex(modelId, ctxWindow, opts) { toolLabel: "Codex", hostCommand: "omniroute configure codex", allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]), + dryRun: Boolean(opts.dryRun ?? opts["dry-run"]), }); if (guard !== 0) return guard; + if (opts.dryRun ?? opts["dry-run"]) { + const profile = opts.name || profileNameFromModel(modelId); + const filePath = path.join(codexHome, `${profile}.config.toml`); + printInfo(`[dry-run] would write ${filePath}`); + return 0; + } if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true }); const profile = opts.name || profileNameFromModel(modelId); const filePath = path.join(codexHome, `${profile}.config.toml`); @@ -97,16 +181,22 @@ async function configureCodex(modelId, ctxWindow, opts) { } export async function runConfigureCommand(cli, opts = {}, cmd) { - const target = String(cli || "").toLowerCase(); - if (!SUPPORTED.includes(target)) { + const target = resolveManifestTarget(cli, "configure"); + if (!target) { printError(`Unsupported CLI '${cli}'. Supported: ${SUPPORTED.join(", ")}.`); return 2; } + if (opts.favorite && opts.unfavorite) { + printError("Choose only one of --favorite or --unfavorite."); + return 2; + } const globalOpts = cmd ? cmd.optsWithGlobals() : {}; + const requestOpts = resolveConfigureTargetOptions({ ...globalOpts, ...opts }); + const contextKey = preferenceContextName({ ...globalOpts, ...opts }); let models; try { - models = await fetchModels(globalOpts); + models = await fetchModels(requestOpts); } catch (e) { printError(e instanceof Error ? e.message : String(e)); return 1; @@ -122,12 +212,15 @@ export async function runConfigureCommand(cli, opts = {}, cmd) { chosenId = `${opts.provider}/${chosenId}`; } - if (!chosenId) { + if (!chosenId && !opts.yes) { const ids = models.map((m) => (typeof m === "string" ? m : m.id)); + const preferences = loadModelPreferences(); + const rankedIds = rankPreferredModels(target, ids, preferences, contextKey); + const preferenceState = getModelPreferenceState(target, preferences, contextKey); const providers = [...new Set(models.map(providerOf))].sort(); const prompt = createPrompt(); try { - printHeading("Configure Codex CLI"); + printHeading(`Configure ${target} CLI`); let providerList = providers; if (opts.provider) { providerList = providers.filter((p) => p === opts.provider); @@ -136,8 +229,18 @@ export async function runConfigureCommand(cli, opts = {}, cmd) { const p = await prompt.ask("Provider"); if (p) providerList = providers.filter((x) => x === p); } - const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id)))); - const candidates = inProvider.length ? inProvider : ids; + const inProvider = rankedIds.filter((id) => + providerList.includes(providerOf(byId(models, id))) + ); + const candidates = inProvider.length ? inProvider : rankedIds; + if (preferenceState.favorites.length) { + printInfo( + `Favorites: ${preferenceState.favorites.filter((id) => ids.includes(id)).join(", ")}` + ); + } + if (preferenceState.recent.length) { + printInfo(`Recent: ${preferenceState.recent.filter((id) => ids.includes(id)).join(", ")}`); + } printInfo( `Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}` ); @@ -158,10 +261,48 @@ export async function runConfigureCommand(cli, opts = {}, cmd) { } const ctxWindow = contextWindowOf(entry); + let result; if (target === "codex") { - return await configureCodex(chosenId, ctxWindow, opts); + result = await configureCodex(chosenId, ctxWindow, opts); + } else { + const setup = SETUP_MODULES[target]; + if (!setup) { + printError(`No setup recipe is registered for '${target}'.`); + return 2; + } + + try { + const module = await import(setup.module); + const runSetup = module[setup.exportName]; + if (typeof runSetup !== "function") { + printError(`Setup recipe '${target}' is unavailable.`); + return 1; + } + + const setupOpts = { + ...requestOpts, + ...opts, + model: chosenId, + // The picker already selected a model. Setup recipes that can generate + // a model subset receive an exact filter; the others use `model`. + ...(target === "claude" || target === "continue" ? { only: chosenId } : {}), + yes: true, + }; + result = await runSetup(setupOpts); + } catch (error) { + printError(error instanceof Error ? error.message : String(error)); + return 1; + } } - return 0; + + if (result === 0 && !(opts.dryRun ?? opts["dry-run"])) { + recordModelPreference(target, chosenId, { + favorite: Boolean(opts.favorite), + unfavorite: Boolean(opts.unfavorite), + context: contextKey, + }); + } + return result; } function byId(models, id) { @@ -177,12 +318,20 @@ export function registerConfigure(program) { .command("configure ") .description( t("configure.description") || - "Pick a provider+model from the active server and write a local CLI config (v1: codex)" + "Pick a provider+model from the active server and configure a supported local CLI" ) + .option("--port ", "Local OmniRoute port (ignored when --remote is set)", "20128") + .option("--remote ", "Remote OmniRoute URL") + .option("--context ", "Named local/remote context") + .option("--api-key ", "OmniRoute API key (defaults to the active context/env)") .option("--provider ", "Provider id (skips the interactive provider prompt)") .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("--yes", "Non-interactive; requires --model") + .option("--favorite", "Remember the selected model as a favorite for this CLI") + .option("--unfavorite", "Remove the selected model from this CLI's favorites") + .option("--dry-run", "Preview the generated config without writing") .option( "--allow-container-write", "Write the config even when OmniRoute runs in a container and the target is not mounted from the host" diff --git a/bin/cli/commands/connect.mjs b/bin/cli/commands/connect.mjs index b7ec71ae97..f5c53c0a4d 100644 --- a/bin/cli/commands/connect.mjs +++ b/bin/cli/commands/connect.mjs @@ -1,5 +1,5 @@ import { apiFetch } from "../api.mjs"; -import { loadContexts, saveContexts } from "../contexts.mjs"; +import { loadContexts, saveContextsSecure } from "../contexts.mjs"; import { createPrompt, printSuccess, printError, printInfo } from "../io.mjs"; import { t } from "../i18n.mjs"; @@ -31,7 +31,9 @@ export function normalizeBaseUrl(host, port) { /** Derive a clean context name from a host (strip scheme/port). */ export function hostLabel(host) { - let value = String(host || "").trim().replace(/^https?:\/\//i, ""); + let value = String(host || "") + .trim() + .replace(/^https?:\/\//i, ""); value = value.split("/")[0].split(":")[0]; return value || "remote"; } @@ -107,7 +109,7 @@ export async function runConnectCommand(host, opts = {}) { description: `Remote OmniRoute (${host})`, }; cfg.currentContext = name; - saveContexts(cfg); + await saveContextsSecure(cfg); printSuccess(`Connected to ${baseUrl} — context '${name}' (scope: ${scope})`); printInfo("All commands now target this server."); diff --git a/bin/cli/commands/contexts.mjs b/bin/cli/commands/contexts.mjs index e40b9ac2ee..5577a08220 100644 --- a/bin/cli/commands/contexts.mjs +++ b/bin/cli/commands/contexts.mjs @@ -1,21 +1,34 @@ import { t } from "../i18n.mjs"; import { emit } from "../output.mjs"; -import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs"; +import { + loadContexts, + saveContextsSecure, + deleteContextCredential, + migrateContextCredentials, + resolveActiveContext, +} from "../contexts.mjs"; /** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */ function authLabel(c) { if (c?.accessToken) return "token"; if (c?.apiKey) return "key"; + if (c?.credentialRef) return "keychain"; return "✗"; } +function contextMap(config) { + return config.contexts || config.profiles || {}; +} + export async function confirm(msg) { // Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking // anyway leaves the readline question pending forever — Node then warns about an // "unsettled top-level await" at exit. Decline cleanly instead and point at the // non-interactive escape hatch so scripted callers fail safe rather than hang. if (!process.stdin.isTTY) { - process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`); + process.stderr.write( + `${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n` + ); return false; } const readline = await import("node:readline"); @@ -31,6 +44,18 @@ function maskKey(k) { return `${k.slice(0, 6)}***${k.slice(-4)}`; } +/** Return an export-safe copy without legacy or canonical context credentials. */ +export function redactContextSecrets(config) { + const out = JSON.parse(JSON.stringify(config || {})); + for (const collection of [out.contexts, out.profiles]) { + for (const context of Object.values(collection || {})) { + context.apiKey = null; + delete context.accessToken; + } + } + return out; +} + export function registerContexts(program) { const ctx = program .command("contexts") @@ -43,7 +68,7 @@ export function registerContexts(program) { .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const cfg = loadContexts(); - const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({ + const rows = Object.entries(contextMap(cfg)).map(([name, c]) => ({ active: name === (cfg.currentContext || "default") ? "●" : "", name, baseUrl: c.baseUrl || "", @@ -73,7 +98,7 @@ export function registerContexts(program) { .option("--description ", "Context description") .action(async (name, opts) => { const cfg = loadContexts(); - if (cfg.contexts?.[name]) { + if (contextMap(cfg)[name]) { process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`); process.exit(2); } @@ -86,29 +111,29 @@ export function registerContexts(program) { if (opts.accessTokenStdin) accessToken = value; else apiKey = value; } - cfg.contexts = cfg.contexts || {}; - cfg.contexts[name] = { + const contexts = contextMap(cfg); + contexts[name] = { baseUrl: opts.url, accessToken: accessToken || undefined, apiKey, scope: opts.scope || undefined, description: opts.description || undefined, }; - saveContexts(cfg); + await saveContextsSecure(cfg); process.stdout.write(`Added context '${name}'\n`); }); ctx .command("use ") .description("Switch active context") - .action((name) => { + .action(async (name) => { const cfg = loadContexts(); - if (!cfg.contexts?.[name]) { + if (!contextMap(cfg)[name]) { process.stderr.write(`No such context: ${name}\n`); process.exit(2); } cfg.currentContext = name; - saveContexts(cfg); + await saveContextsSecure(cfg); process.stdout.write(`Active context: ${name}\n`); }); @@ -143,7 +168,7 @@ export function registerContexts(program) { .action((name, opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const cfg = loadContexts(); - const c = cfg.contexts?.[name]; + const c = contextMap(cfg)[name]; if (!c) { process.stderr.write(`No such context: ${name}\n`); process.exit(2); @@ -151,6 +176,8 @@ export function registerContexts(program) { const display = { name, baseUrl: c.baseUrl, + auth: authLabel(c), + credentialRef: c.credentialRef || null, accessToken: maskKey(c.accessToken), apiKey: maskKey(c.apiKey), scope: c.scope, @@ -172,7 +199,7 @@ export function registerContexts(program) { } } const cfg = loadContexts(); - if (!cfg.contexts?.[name]) { + if (!contextMap(cfg)[name]) { process.stderr.write(`No such context: ${name}\n`); process.exit(2); } @@ -180,29 +207,37 @@ export function registerContexts(program) { process.stderr.write("Cannot remove default context.\n"); process.exit(2); } - delete cfg.contexts[name]; + const contexts = contextMap(cfg); + const deletedCredential = await deleteContextCredential(name, contexts[name]); + if (contexts[name].credentialRef && !deletedCredential) { + process.stderr.write( + "Warning: could not remove the OS-keychain entry; the context reference was removed locally.\n" + ); + } + delete contexts[name]; if (cfg.currentContext === name) cfg.currentContext = "default"; - saveContexts(cfg); + await saveContextsSecure(cfg); process.stdout.write(`Removed context '${name}'\n`); }); ctx .command("rename ") .description("Rename a context") - .action((oldName, newName) => { + .action(async (oldName, newName) => { const cfg = loadContexts(); - if (!cfg.contexts?.[oldName]) { + const contexts = contextMap(cfg); + if (!contexts[oldName]) { process.stderr.write(`No such context: ${oldName}\n`); process.exit(2); } - if (cfg.contexts[newName]) { + if (contexts[newName]) { process.stderr.write(`Context '${newName}' already exists.\n`); process.exit(2); } - cfg.contexts[newName] = cfg.contexts[oldName]; - delete cfg.contexts[oldName]; + contexts[newName] = contexts[oldName]; + delete contexts[oldName]; if (cfg.currentContext === oldName) cfg.currentContext = newName; - saveContexts(cfg); + await saveContextsSecure(cfg); process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`); }); @@ -213,13 +248,7 @@ export function registerContexts(program) { .option("--no-secrets", "Omit API keys from export") .action(async (opts, cmd) => { const cfg = loadContexts(); - const out = JSON.parse(JSON.stringify(cfg)); - if (opts.noSecrets) { - for (const c of Object.values(out.contexts || {})) { - c.apiKey = null; - delete c.accessToken; - } - } + const out = opts.noSecrets ? redactContextSecrets(cfg) : JSON.parse(JSON.stringify(cfg)); const json = JSON.stringify(out, null, 2); if (opts.out) { const { writeFileSync } = await import("node:fs"); @@ -248,7 +277,12 @@ export function registerContexts(program) { const cfg = opts.merge ? loadContexts() : { version: 1, currentContext: "default", contexts: {} }; - const incoming = imported.contexts || {}; + if (!cfg.contexts && cfg.profiles) { + cfg.contexts = cfg.profiles; + delete cfg.profiles; + } + cfg.contexts = cfg.contexts || {}; + const incoming = imported.contexts || imported.profiles || {}; let count = 0; for (const [name, raw] of Object.entries(incoming)) { if (typeof name !== "string" || !name) continue; @@ -265,7 +299,38 @@ export function registerContexts(program) { if (!opts.merge && typeof imported.currentContext === "string") { cfg.currentContext = imported.currentContext; } - saveContexts(cfg); + await saveContextsSecure(cfg); process.stdout.write(`Imported ${count} context(s)\n`); }); + + ctx + .command("migrate") + .description("Move legacy plaintext context credentials to the OS keychain") + .option("--yes", "Confirm migration in non-interactive scripts") + .action(async (opts) => { + const cfg = loadContexts(); + const pending = Object.entries(cfg.contexts || cfg.profiles || {}).filter( + ([, context]) => context?.accessToken || context?.apiKey + ); + if (!pending.length) { + process.stdout.write("No plaintext context credentials found.\n"); + return; + } + if ( + !opts.yes && + !(await confirm(`Migrate ${pending.length} context credential(s) to keychain?`)) + ) { + process.stdout.write("Cancelled.\n"); + return; + } + const result = await migrateContextCredentials(); + if (!result.migrated) { + process.stderr.write( + "OS keychain unavailable; credentials remain in config.json mode 0600.\n" + ); + process.exitCode = 2; + return; + } + process.stdout.write(`Migrated ${pending.length} context credential(s) to keychain.\n`); + }); } diff --git a/bin/cli/commands/launch-codex.mjs b/bin/cli/commands/launch-codex.mjs index eee459c81f..a2613cf464 100644 --- a/bin/cli/commands/launch-codex.mjs +++ b/bin/cli/commands/launch-codex.mjs @@ -229,18 +229,45 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) { stdio: "inherit", shell: shellValue, }); + let settled = false; + const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; + const signalHandlers = {}; + const cleanupSignalHandlers = () => { + for (const signal of Object.keys(signalExitCode)) { + process.removeListener(signal, signalHandlers[signal]); + } + }; + const finish = (code) => { + if (settled) return; + settled = true; + cleanupSignalHandlers(); + resolve(code); + }; + for (const signal of Object.keys(signalExitCode)) { + signalHandlers[signal] = () => { + try { + child.kill(signal); + } catch { + // The child may have already exited between the signal and cleanup. + } + finish(signalExitCode[signal]); + }; + process.once(signal, signalHandlers[signal]); + } child.on("error", (err) => { if (err?.code === "ENOENT") { console.error( "The 'codex' CLI was not found in PATH. Install with:\n npm install -g @openai/codex" ); - resolve(127); + finish(127); } else { console.error(String(err?.message || err)); - resolve(1); + finish(1); } }); - child.on("exit", (code) => resolve(code ?? 0)); + child.on("exit", (code, signalName) => { + finish(code ?? signalExitCode[signalName] ?? 0); + }); }); } diff --git a/bin/cli/commands/launch.mjs b/bin/cli/commands/launch.mjs index 78983473e7..e9ef265e7b 100644 --- a/bin/cli/commands/launch.mjs +++ b/bin/cli/commands/launch.mjs @@ -204,16 +204,43 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) { shell, ...(process.platform === "win32" ? { windowsHide: true } : {}), }); + let settled = false; + const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; + const signalHandlers = {}; + const cleanupSignalHandlers = () => { + for (const signal of Object.keys(signalExitCode)) { + process.removeListener(signal, signalHandlers[signal]); + } + }; + const finish = (code) => { + if (settled) return; + settled = true; + cleanupSignalHandlers(); + resolve(code); + }; + for (const signal of Object.keys(signalExitCode)) { + signalHandlers[signal] = () => { + try { + child.kill(signal); + } catch { + // The child may have already exited between the signal and cleanup. + } + finish(signalExitCode[signal]); + }; + process.once(signal, signalHandlers[signal]); + } child.on("error", (err) => { if (err && err.code === "ENOENT") { console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH."); - resolve(127); + finish(127); } else { console.error(String(err?.message || err)); - resolve(1); + finish(1); } }); - child.on("exit", (code) => resolve(code ?? 0)); + child.on("exit", (code, signalName) => { + finish(code ?? signalExitCode[signalName] ?? 0); + }); }); } diff --git a/bin/cli/commands/oauth.mjs b/bin/cli/commands/oauth.mjs index 8a1d170ad6..8bf547b2c0 100644 --- a/bin/cli/commands/oauth.mjs +++ b/bin/cli/commands/oauth.mjs @@ -54,11 +54,20 @@ async function openBrowser(url) { } } -async function pollStatus(endpoint, timeoutMs) { +function targetApiOptions(opts = {}) { + return { + baseUrl: opts.baseUrl, + context: opts.context, + apiKey: opts.apiKey, + timeout: opts.timeout, + }; +} + +async function pollStatus(endpoint, timeoutMs, opts = {}) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await sleep(2000); - const res = await apiFetch(endpoint); + const res = await apiFetch(endpoint, targetApiOptions(opts)); if (!res.ok) continue; const data = await res.json(); if (data.status === "complete" || data.status === "completed") return data; @@ -85,7 +94,7 @@ async function runBrowserFlow(def, opts) { const authorizeUrl = `/api/oauth/${backendKey}/authorize${ redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : "" }`; - const startRes = await apiFetch(authorizeUrl, { method: "GET" }); + const startRes = await apiFetch(authorizeUrl, { ...targetApiOptions(opts), method: "GET" }); if (!startRes.ok) { const detail = await safeErrorBody(startRes); process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`); @@ -143,6 +152,7 @@ async function runBrowserFlow(def, opts) { } const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, { + ...targetApiOptions(opts), method: "POST", body: { code, @@ -179,7 +189,7 @@ async function runImportFlow(def, opts) { const endpoint = opts.importFromSystem ? `/api/oauth/${def.id}/auto-import` : `/api/oauth/${def.id}/import`; - const res = await apiFetch(endpoint, { method: "POST" }); + const res = await apiFetch(endpoint, { ...targetApiOptions(opts), method: "POST" }); if (!res.ok) { process.stderr.write(`Import failed: ${res.status}\n`); process.exit(1); @@ -195,6 +205,7 @@ async function runSocialFlow(def, opts) { process.exit(2); } const startRes = await apiFetch(`/api/oauth/${def.id}/social-authorize`, { + ...targetApiOptions(opts), method: "POST", body: { social }, }); @@ -209,14 +220,18 @@ async function runSocialFlow(def, opts) { process.stderr.write("Waiting for social authorization...\n"); const result = await pollStatus( `/api/oauth/${def.id}/social-exchange?state=${encodeURIComponent(start.state ?? "")}`, - opts.timeout ?? 300000 + opts.timeout ?? 300000, + opts ); process.stdout.write(`Authorized: ${result.email ?? result.userId ?? "connected"}\n`); } async function runDeviceFlow(def, opts) { const providerKey = resolveBackendKey(def.id); - const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" }); + const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { + ...targetApiOptions(opts), + method: "POST", + }); if (!startRes.ok) { process.stderr.write(`Failed to start device flow: ${startRes.status}\n`); process.exit(1); @@ -233,12 +248,14 @@ async function runDeviceFlow(def, opts) { while (Date.now() < deadline) { await sleep(intervalMs); const statusRes = await apiFetch( - `/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}` + `/api/providers/${providerKey}/auth/status?state=${encodeURIComponent(start.state ?? "")}`, + targetApiOptions(opts) ); if (!statusRes.ok) continue; const status = await statusRes.json(); if (status.status === "complete" || status.status === "authorized") { await apiFetch(`/api/providers/${providerKey}/auth/apply`, { + ...targetApiOptions(opts), method: "POST", body: { state: start.state }, }); @@ -255,6 +272,7 @@ async function runDeviceFlow(def, opts) { } export async function runOAuthStart(opts, cmd) { + opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts }; const def = PROVIDERS_WITH_OAUTH.find((p) => p.id === opts.provider); if (!def) { process.stderr.write( @@ -275,22 +293,23 @@ export async function runOAuthStart(opts, cmd) { } export async function runOAuthStatus(opts, cmd) { - const globalOpts = cmd.optsWithGlobals(); + const globalOpts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts }; const params = new URLSearchParams(); if (opts.provider) params.set("provider", opts.provider); - const res = await apiFetch(`/api/providers?${params}`); + const res = await apiFetch(`/api/providers?${params}`, targetApiOptions(globalOpts)); if (!res.ok) { process.stderr.write(`Error: ${res.status}\n`); process.exit(1); } const data = await res.json(); - const connections = (data.providers ?? data.items ?? data).filter( + const connections = (data.connections ?? data.providers ?? data.items ?? data).filter( (c) => c.authType === "oauth" || c.authType === "oauth2" ); emit(connections, globalOpts, connectionSchema); } export async function runOAuthRevoke(opts, cmd) { + opts = { ...(cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {}), ...opts }; if (!opts.yes) { process.stdout.write( `Revoke OAuth for ${opts.provider}${opts.connectionId ? ` (${opts.connectionId})` : ""}? (yes/no) ` @@ -303,8 +322,11 @@ export async function runOAuthRevoke(opts, cmd) { } const id = opts.connectionId; const res = id - ? await apiFetch(`/api/providers/${id}`, { method: "DELETE" }) - : await apiFetch(`/api/oauth/${opts.provider}/revoke`, { method: "POST" }); + ? await apiFetch(`/api/providers/${id}`, { ...targetApiOptions(opts), method: "DELETE" }) + : await apiFetch(`/api/oauth/${opts.provider}/revoke`, { + ...targetApiOptions(opts), + method: "POST", + }); if (!res.ok) { process.stderr.write(`Error: ${res.status}\n`); process.exit(1); diff --git a/bin/cli/commands/provider-cmd.mjs b/bin/cli/commands/provider-cmd.mjs index e6e44183ea..52c3b85728 100644 --- a/bin/cli/commands/provider-cmd.mjs +++ b/bin/cli/commands/provider-cmd.mjs @@ -13,6 +13,9 @@ export function registerProvider(program) { omniroute providers test — test a provider connection omniroute providers test-all — test all active connections omniroute providers validate — validate local configuration + omniroute providers add — add an API-key connection + omniroute providers auth — start an existing OAuth flow + omniroute providers remove — remove a connection (requires confirmation) `); }); } diff --git a/bin/cli/commands/provider-crud.mjs b/bin/cli/commands/provider-crud.mjs new file mode 100644 index 0000000000..fa77bb603e --- /dev/null +++ b/bin/cli/commands/provider-crud.mjs @@ -0,0 +1,498 @@ +import { readFileSync } from "node:fs"; + +import { apiFetch, statusToExitCode } from "../api.mjs"; +import { createPrompt, printError, printInfo, printSuccess } from "../io.mjs"; +import { runOAuthStart } from "./oauth.mjs"; + +const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function isBlank(value) { + return value === undefined || value === null || String(value).trim() === ""; +} + +function credentialShape(value) { + if (isBlank(value)) return { present: false, length: 0 }; + return { present: true, length: String(value).length }; +} + +const SENSITIVE_FIELD_RE = + /^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|password|secret|client[_-]?secret|credential|authorization)$/i; + +/** + * Redact provider responses before they reach human or JSON output. + * + * The API normally masks credentials, but the CLI must remain safe when an + * operator enables a server-side reveal/debug option or when a compatible + * remote implementation returns a raw field. Presence and length are useful + * for diagnostics; the value itself must never be printed. + */ +export function redactProviderResponse(value, key = "") { + if (SENSITIVE_FIELD_RE.test(key)) { + if (value === null || value === undefined || value === "") return null; + return typeof value === "string" ? credentialShape(value) : "[redacted]"; + } + if (Array.isArray(value)) return value.map((entry) => redactProviderResponse(entry)); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactProviderResponse(entryValue, entryKey), + ]) + ); +} + +/** + * Extract a provider connection from the response returned by /api/providers. + * The server deliberately masks credentials, so this helper never needs to + * inspect or log a secret. + */ +export function findConnectionFromResponse(body, selector) { + const rows = Array.isArray(body?.connections) + ? body.connections + : Array.isArray(body?.providers) + ? body.providers + : Array.isArray(body) + ? body + : []; + const needle = String(selector || "") + .trim() + .toLowerCase(); + if (!needle) return null; + return ( + rows.find((row) => String(row?.id || "").toLowerCase() === needle) || + rows.find((row) => + String(row?.id || "") + .toLowerCase() + .startsWith(needle) + ) || + rows.find((row) => String(row?.name || "").toLowerCase() === needle) || + rows.find((row) => String(row?.provider || "").toLowerCase() === needle) || + null + ); +} + +/** Build the API body without accepting management auth as a provider secret. */ +export function buildProviderPayload(provider, opts = {}, credential) { + const body = { + provider: String(provider || "").trim(), + name: String(opts.name || provider || "").trim(), + }; + if (!body.name) throw new Error("Provider name is required."); + if (!isBlank(credential)) body.apiKey = String(credential); + if (!isBlank(opts.defaultModel)) body.defaultModel = String(opts.defaultModel).trim(); + if (!isBlank(opts.priority)) { + const priority = Number(opts.priority); + if (!Number.isInteger(priority) || priority < 1) { + throw new Error("--priority must be a positive integer."); + } + body.priority = priority; + } + if (opts.providerSpecificData) { + const raw = typeof opts.providerSpecificData === "string" ? opts.providerSpecificData : null; + try { + const parsed = raw ? JSON.parse(raw) : opts.providerSpecificData; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("must be a JSON object"); + } + body.providerSpecificData = parsed; + } catch (error) { + throw new Error( + `--provider-specific-data must be a JSON object (${error instanceof Error ? error.message : String(error)})` + ); + } + } + return body; +} + +/** Resolve a credential from an explicit value, env reference, stdin, or prompt. */ +export async function resolveProviderCredential(opts = {}, { prompt = true } = {}) { + // Commander represents the negated `--no-credential` option as + // `credential === false`. It is a control flag, never the literal provider + // credential "false". + if (opts.credential === false || opts.noCredential === true) return undefined; + if (!isBlank(opts.credential)) return String(opts.credential).trim(); + + const envName = String(opts.credentialEnv || opts["credential-env"] || "").trim(); + if (envName) { + if (!ENV_NAME_RE.test(envName)) throw new Error("--credential-env must be a valid env name."); + const value = process.env[envName]; + if (isBlank(value)) throw new Error(`Environment variable ${envName} is empty or unset.`); + return String(value).trim(); + } + + if (opts.credentialStdin || opts["credential-stdin"]) { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + const value = chunks.join("").trim(); + if (!value) throw new Error("Credential stdin was empty."); + return value; + } + + if (!prompt) return undefined; + const input = createPrompt(); + try { + const value = await input.askSecret("Provider credential (hidden)"); + const trimmed = String(value || "").trim(); + if (!trimmed) throw new Error("Provider credential is required."); + return trimmed; + } finally { + input.close(); + } +} + +function targetOptions(opts = {}) { + return { + // Passing the global values through lets api.mjs apply its context-first + // auth precedence. A caller-supplied --base-url remains an explicit target. + baseUrl: opts.baseUrl, + context: opts.context, + apiKey: opts.apiKey, + timeout: opts.timeout, + }; +} + +async function readApiError(response) { + try { + const body = await response.json(); + const message = body?.error?.message || body?.error || body?.message; + return message ? String(message) : `HTTP ${response.status}`; + } catch { + return `HTTP ${response.status}`; + } +} + +async function listRemoteConnections(opts) { + return apiFetch("/api/providers?limit=5000", { + ...targetOptions(opts), + acceptNotOk: true, + retry: false, + }); +} + +async function resolveRemoteConnection(selector, opts) { + const response = await listRemoteConnections(opts); + if (!response.ok) { + throw new Error(await readApiError(response)); + } + const connection = findConnectionFromResponse(await response.json(), selector); + if (!connection) throw new Error(`Provider connection not found: ${selector}`); + return connection; +} + +export async function runProviderAddCommand(provider, opts = {}) { + const normalized = String(provider || "").trim(); + if (!normalized) { + printError("Provider id is required."); + return 2; + } + if (opts.oauth) { + if (opts.dryRun) { + if (!opts.silent) { + const preview = { action: "providers.auth", provider: normalized }; + if (opts.json) console.log(JSON.stringify(preview, null, 2)); + else printInfo(`dry-run: would start OAuth for ${normalized}`); + } + return 0; + } + return runOAuthStart({ ...opts, provider: normalized }, opts.command); + } + + const allowNoCredential = Boolean( + opts.allowNoCredential || opts.noCredential || opts.credential === false + ); + let credential; + try { + credential = await resolveProviderCredential(opts, { + prompt: !opts.dryRun && !opts.yes && !allowNoCredential, + }); + if (!credential && !opts.dryRun && !allowNoCredential) { + throw new Error( + "Provider credential is required (use --credential-stdin or --credential-env)." + ); + } + const payload = buildProviderPayload(normalized, opts, credential); + if (opts.dryRun) { + const preview = { + action: "providers.add", + provider: payload.provider, + name: payload.name, + defaultModel: payload.defaultModel || null, + credential: credentialShape(credential), + providerSpecificData: payload.providerSpecificData + ? redactProviderResponse(payload.providerSpecificData) + : null, + }; + if (!opts.silent) { + if (opts.json) console.log(JSON.stringify(preview, null, 2)); + else printInfo(`dry-run: would add ${payload.provider}/${payload.name}`); + } + return 0; + } + + const response = await apiFetch("/api/providers", { + ...targetOptions(opts), + method: "POST", + body: payload, + acceptNotOk: true, + retry: false, + }); + if (!response.ok) { + printError(await readApiError(response)); + return statusToExitCode(response.status); + } + const body = await response.json().catch(() => ({})); + if (!opts.silent) { + if (opts.json) console.log(JSON.stringify(redactProviderResponse(body), null, 2)); + else printSuccess(`Added provider connection '${body?.connection?.name || payload.name}'.`); + } + return 0; + } catch (error) { + printError(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +export async function runProviderImportCommand(file, opts = {}) { + let parsed; + try { + parsed = JSON.parse(readFileSync(file, "utf8")); + } catch (error) { + printError( + `Cannot read provider import file: ${error instanceof Error ? error.message : String(error)}` + ); + return 1; + } + const entries = Array.isArray(parsed) + ? parsed + : Array.isArray(parsed?.providers) + ? parsed.providers + : [parsed]; + if (!entries.length) { + printError("Provider import file contains no entries."); + return 2; + } + const results = []; + for (const entry of entries) { + if (!entry || typeof entry !== "object" || !entry.provider) { + results.push({ ok: false, error: "entry.provider is required" }); + if (!opts.continueOnError) break; + continue; + } + const code = await runProviderAddCommand(entry.provider, { + ...opts, + ...entry, + credential: entry.apiKey ?? entry.credential, + dryRun: opts.dryRun, + yes: true, + silent: true, + allowNoCredential: entry.allowNoCredential ?? opts.allowNoCredential, + }); + results.push({ provider: entry.provider, ok: code === 0, code }); + if (code !== 0 && !opts.continueOnError) break; + } + if (opts.json) console.log(JSON.stringify({ file, results }, null, 2)); + return results.every((result) => result.ok) ? 0 : 1; +} + +async function confirmRemoval(label, opts) { + if (opts.yes) return true; + if (!process.stdin.isTTY) { + printError(`Removal of '${label}' declined on non-interactive stdin; pass --yes to confirm.`); + return false; + } + const prompt = createPrompt(); + try { + const answer = await prompt.ask(`Remove provider connection '${label}'? [y/N] `); + return /^y(?:es)?$/i.test(String(answer || "").trim()); + } finally { + prompt.close(); + } +} + +export async function runProviderRemoveCommand(selector, opts = {}) { + if (!selector) { + printError("Provider connection id, name, or provider is required."); + return 2; + } + try { + if (opts.dryRun) { + const connection = await resolveRemoteConnection(selector, opts); + if (opts.json) { + console.log( + JSON.stringify( + redactProviderResponse({ action: "providers.remove", connection }), + null, + 2 + ) + ); + } else printInfo(`dry-run: would remove ${connection.name || connection.id}`); + return 0; + } + const connection = await resolveRemoteConnection(selector, opts); + if (!(await confirmRemoval(connection.name || connection.id, opts))) return 0; + const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, { + ...targetOptions(opts), + method: "DELETE", + acceptNotOk: true, + retry: false, + }); + if (!response.ok) { + printError(await readApiError(response)); + return statusToExitCode(response.status); + } + if (opts.json) + console.log(JSON.stringify(redactProviderResponse({ removed: connection }), null, 2)); + else printSuccess(`Removed provider connection '${connection.name || connection.id}'.`); + return 0; + } catch (error) { + printError(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +export async function runProviderEditCommand(selector, opts = {}) { + try { + const connection = await resolveRemoteConnection(selector, opts); + const body = {}; + if (opts.name !== undefined) body.name = opts.name; + if (opts.defaultModel !== undefined) body.defaultModel = opts.defaultModel || null; + if (opts.priority !== undefined) body.priority = Number(opts.priority); + if (opts.active !== undefined) body.isActive = Boolean(opts.active); + if (opts.inactive !== undefined) body.isActive = false; + const credential = await resolveProviderCredential(opts, { prompt: false }); + if (credential) body.apiKey = credential; + if (Object.keys(body).length === 0) { + printError( + "At least one edit field is required (--name, --default-model, --priority, --active/--inactive, or credential)." + ); + return 2; + } + if (opts.dryRun) { + const preview = { + action: "providers.edit", + connection: redactProviderResponse(connection), + changes: { ...body, apiKey: credentialShape(body.apiKey) }, + }; + if (opts.json) console.log(JSON.stringify(preview, null, 2)); + else printInfo(`dry-run: would edit ${connection.name || connection.id}`); + return 0; + } + const response = await apiFetch(`/api/providers/${encodeURIComponent(connection.id)}`, { + ...targetOptions(opts), + method: "PUT", + body, + acceptNotOk: true, + retry: false, + }); + if (!response.ok) { + printError(await readApiError(response)); + return statusToExitCode(response.status); + } + const result = await response.json().catch(() => ({})); + if (opts.json) console.log(JSON.stringify(redactProviderResponse(result), null, 2)); + else printSuccess(`Updated provider connection '${connection.name || connection.id}'.`); + return 0; + } catch (error) { + printError(error instanceof Error ? error.message : String(error)); + return 1; + } +} + +export async function runProviderAuthCommand(provider, opts = {}, cmd) { + return runOAuthStart({ ...opts, provider }, cmd); +} + +export function registerProviderCrud(providers) { + providers + .command("add ") + .description("Add an API-key provider connection through the active local/remote server") + .option("--name ", "Connection name (defaults to provider id)") + .option( + "--credential ", + "Provider credential (prefer --credential-stdin or --credential-env)" + ) + .option("--credential-env ", "Read provider credential from an environment variable") + .option("--credential-stdin", "Read provider credential from stdin") + .option("--allow-no-credential", "Allow providers whose catalog marks the credential optional") + .option("--no-credential", "Allow providers whose catalog marks the credential optional") + .option("--default-model ", "Default model for this connection") + .option("--priority ", "Connection priority", Number) + .option("--provider-specific-data ", "Provider-specific settings as a JSON object") + .option("--oauth", "Start the provider's existing OAuth flow instead") + .option("--yes", "Do not prompt for a credential") + .option("--dry-run", "Preview the request without writing") + .option("--json", "Print machine-readable output") + .action(async (provider, opts, cmd) => { + const code = await runProviderAddCommand(provider, { + ...cmd.parent.optsWithGlobals(), + ...opts, + command: cmd, + }); + if (code !== 0) process.exit(code); + }); + + providers + .command("import ") + .description("Import provider connections from a JSON file") + .option("--continue-on-error", "Continue importing after a failed entry") + .option("--dry-run", "Preview requests without writing") + .option("--json", "Print machine-readable output") + .action(async (file, opts, cmd) => { + const code = await runProviderImportCommand(file, { + ...cmd.parent.optsWithGlobals(), + ...opts, + }); + if (code !== 0) process.exit(code); + }); + + providers + .command("auth ") + .description("Start an existing OAuth flow for a provider") + .option("--no-browser", "Print the authorization URL instead of opening a browser") + .option("--import-from-system", "Import credentials from the local system when supported") + .option("--social ", "Use a social-login flow when supported") + .option("--timeout ", "OAuth timeout", Number, 300000) + .action(async (provider, opts, cmd) => { + const code = await runProviderAuthCommand( + provider, + { ...cmd.parent.optsWithGlobals(), ...opts }, + cmd + ); + if (code !== 0) process.exit(code); + }); + + providers + .command("remove ") + .description("Remove one provider connection from the active local/remote server") + .option("--yes", "Confirm removal") + .option("--dry-run", "Preview the removal without writing") + .option("--json", "Print machine-readable output") + .action(async (idOrName, opts, cmd) => { + const code = await runProviderRemoveCommand(idOrName, { + ...cmd.parent.optsWithGlobals(), + ...opts, + }); + if (code !== 0) process.exit(code); + }); + + providers + .command("edit ") + .description("Edit one provider connection on the active local/remote server") + .option("--name ", "New connection name") + .option("--default-model ", "New default model") + .option("--priority ", "New connection priority", Number) + .option("--active", "Activate the connection") + .option("--inactive", "Deactivate the connection") + .option("--credential ", "Replace provider credential") + .option("--credential-env ", "Read replacement credential from an environment variable") + .option("--credential-stdin", "Read replacement credential from stdin") + .option("--dry-run", "Preview the edit without writing") + .option("--json", "Print machine-readable output") + .action(async (idOrName, opts, cmd) => { + const code = await runProviderEditCommand(idOrName, { + ...cmd.parent.optsWithGlobals(), + ...opts, + }); + if (code !== 0) process.exit(code); + }); +} diff --git a/bin/cli/commands/providers.mjs b/bin/cli/commands/providers.mjs index 2277329bad..91d60cead8 100644 --- a/bin/cli/commands/providers.mjs +++ b/bin/cli/commands/providers.mjs @@ -13,6 +13,7 @@ import { import { encryptCredential } from "../encryption.mjs"; import { openOmniRouteDb } from "../sqlite.mjs"; import { t } from "../i18n.mjs"; +import { registerProviderCrud } from "./provider-crud.mjs"; function publicConnection(connection) { return { @@ -604,6 +605,8 @@ export function registerProviders(program) { if (exitCode !== 0) process.exit(exitCode); }); + registerProviderCrud(providers); + extendProvidersMetrics(providers); } diff --git a/bin/cli/commands/quota.mjs b/bin/cli/commands/quota.mjs index a657845e51..dee142db53 100644 --- a/bin/cli/commands/quota.mjs +++ b/bin/cli/commands/quota.mjs @@ -2,7 +2,7 @@ import { apiFetch, isServerUp } from "../api.mjs"; import { t } from "../i18n.mjs"; export function registerQuota(program) { - program + const quota = program .command("quota") .description(t("quota.description")) .option("--provider ", "Filter by provider") @@ -12,6 +12,60 @@ export function registerQuota(program) { const exitCode = await runQuotaCommand({ ...opts, output: globalOpts.output }); if (exitCode !== 0) process.exit(exitCode); }); + + quota + .command("status") + .description("Show truthful OmniRoute gateway, quota, pool, and circuit state") + .action(async (opts, cmd) => runBoundedJson("/api/omniroute/status", cmd.optsWithGlobals())); + + quota + .command("preview") + .description("Preview allocation enforcement without an upstream request") + .requiredOption("--api-key-id ", "API key id") + .requiredOption("--pool-id ", "quota pool id") + .option("--tokens ", "estimated token usage") + .action(async (opts, cmd) => { + const params = new URLSearchParams({ apiKeyId: opts.apiKeyId, poolId: opts.poolId }); + if (opts.tokens != null) params.set("estimatedTokens", opts.tokens); + await runBoundedJson(`/api/quota/preview?${params}`, cmd.optsWithGlobals()); + }); + + quota + .command("ensure ") + .description("Idempotently create or update a quota pool from a JSON object") + .action(async (json, opts, cmd) => { + let body; + try { + body = JSON.parse(json); + } catch { + console.error("Invalid pool JSON"); + process.exit(2); + } + await runBoundedJson("/api/quota/pools?ensure=true", cmd.optsWithGlobals(), { + method: "POST", + body, + }); + }); +} + +async function runBoundedJson(path, opts, request = {}) { + const started = performance.now(); + const res = await apiFetch(path, { + ...request, + retry: false, + timeout: Math.min(opts.timeout ?? 5000, 5000), + acceptNotOk: true, + }); + const elapsed = Math.round(performance.now() - started); + if (process.env.OMNIROUTE_DEBUG === "1") { + console.error(`[omniroute] ${request.method ?? "GET"} ${path} completed in ${elapsed}ms`); + } + const payload = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + if (!res.ok) { + console.error(JSON.stringify(payload)); + process.exit(res.exitCode ?? 1); + } + console.log(JSON.stringify(payload, null, 2)); } export async function runQuotaCommand(opts = {}) { diff --git a/bin/cli/commands/run.mjs b/bin/cli/commands/run.mjs index 1d9391f692..31b2b437f6 100644 --- a/bin/cli/commands/run.mjs +++ b/bin/cli/commands/run.mjs @@ -16,28 +16,16 @@ import { import { t } from "../i18n.mjs"; import os from "node:os"; import { join } from "node:path"; +import { spawn, execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { resolveActiveContext } from "../contexts.mjs"; - -const RUN_TARGETS = { - claude: { - aliases: ["claude", "claude-code", "cc"], - description: "Claude Code", - }, - codex: { - aliases: ["codex", "openai-codex", "openai"], - description: "OpenAI Codex CLI", - }, -}; - -/** @type {Record} */ -const RUN_TARGET_ALIAS_TO_CANONICAL = { - claude: "claude", - "claude-code": "claude", - cc: "claude", - codex: "codex", - "openai-codex": "codex", - openai: "codex", -}; +import { quoteShellArgs } from "../utils/winShellArgs.mjs"; +import { + listManifestTargets, + manifestModelArgs, + manifestRequiresModel, + resolveManifestTarget, +} from "../cli-manifest.mjs"; function isBlank(value) { return value === undefined || value === null || String(value).trim() === ""; @@ -48,6 +36,11 @@ function toAuthSource(targetOpts) { !isBlank(targetOpts.token) || !isBlank(targetOpts.apiKey) || !isBlank(targetOpts["api-key"]); if (explicit) return "option"; + const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim(); + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName) && !isBlank(process.env[envName])) { + return "env"; + } + try { const context = resolveActiveContext(targetOpts.context || process.env.OMNIROUTE_CONTEXT); if (context && (context.accessToken || context.apiKey)) return "context"; @@ -60,16 +53,23 @@ function toAuthSource(targetOpts) { return "none"; } -/** Resolve supported target to canonical id. */ +/** Resolve a token option without ever printing its value in a plan. */ +function resolveAuthTokenOption(targetOpts = {}) { + const direct = targetOpts.token || targetOpts.apiKey || targetOpts["api-key"]; + if (!isBlank(direct)) return direct; + + const envName = String(targetOpts.apiKeyEnv || targetOpts["api-key-env"] || "").trim(); + if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(envName)) return process.env[envName]; + return undefined; +} + +/** Resolve supported target (id or alias) to canonical id via the manifest. */ export function resolveRunTarget(target) { - const raw = String(target || "") - .trim() - .toLowerCase(); - return RUN_TARGET_ALIAS_TO_CANONICAL[raw]; + return resolveManifestTarget(target, "run"); } export function listRunTargets() { - return Object.keys(RUN_TARGETS); + return listManifestTargets("run"); } /** @@ -116,8 +116,8 @@ async function buildClaudePlan(rawOpts, args = []) { const merged = { ...rawOpts, model, - apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token, - token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"], + apiKey: resolveAuthTokenOption(rawOpts), + token: resolveAuthTokenOption(rawOpts), profile: rawOpts.profile ?? rawOpts.p, }; @@ -142,7 +142,7 @@ async function buildClaudePlan(rawOpts, args = []) { args: quotedArgs, model: merged.model || undefined, envDiff: envPreview(process.env, env), - authSource: toAuthSource(merged), + authSource: toAuthSource(rawOpts), commandDisplay: describeCommand(commandSpec.command, commandSpec.shell), }; } @@ -151,7 +151,7 @@ async function buildCodexPlan(rawOpts, args = []) { const model = resolveModelFromTargetOptions(rawOpts); const merged = { ...rawOpts, - apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token, + apiKey: resolveAuthTokenOption(rawOpts), model, profile: rawOpts.profile ?? rawOpts.p, }; @@ -174,25 +174,303 @@ async function buildCodexPlan(rawOpts, args = []) { args: quotedArgs, model: merged.model || undefined, envDiff: envPreview(process.env, env), - authSource: toAuthSource(merged), + authSource: toAuthSource(rawOpts), commandDisplay: describeCommand(commandSpec.command, commandSpec.shell), providerArgs, profileArgs, }; } +const NO_AUTH_SENTINEL = "omniroute-no-auth"; + +function resolveGenericSpawn(command) { + if (process.platform !== "win32") return { command, shell: undefined }; + + try { + const output = execFileSync("where.exe", [command], { + stdio: ["ignore", "pipe", "ignore"], + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }); + const matches = output + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); + const preferred = matches.find((value) => /\.exe$/i.test(value)); + if (preferred) return { command: preferred, shell: undefined }; + const shim = matches.find((value) => /\.(?:cmd|bat)$/i.test(value)); + if (shim) return { command: shim, shell: true }; + } catch { + // Fall through to the conventional npm shim. + } + + return { command: `${command}.cmd`, shell: true }; +} + +function genericEnv(baseEnv, kind, baseUrl, authToken, model) { + const env = { ...baseEnv }; + for (const key of Object.keys(env)) { + if (kind === "aider" && /^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key)) { + delete env[key]; + } + if ( + kind === "goose" && + (/^(OPENAI_API_KEY|OPENAI_API_BASE|OPENAI_BASE_URL)$/.test(key) || key.startsWith("GOOSE_")) + ) { + delete env[key]; + } + if (kind === "opencode" && key === "OPENCODE_CONFIG_CONTENT") delete env[key]; + if (kind === "qwen" && (key === "QWEN_HOME" || key === "OMNIROUTE_API_KEY")) { + delete env[key]; + } + if ( + kind === "gemini" && + /^(GOOGLE_GEMINI_BASE_URL|GEMINI_API_KEY|GOOGLE_API_KEY|GEMINI_CLI_HOME|GEMINI_DEFAULT_AUTH_TYPE|GOOGLE_GENAI_USE_VERTEXAI|GOOGLE_GENAI_USE_GCA)$/.test( + key + ) + ) { + delete env[key]; + } + } + + const token = (authToken && String(authToken).trim()) || NO_AUTH_SENTINEL; + if (kind === "aider") { + env.OPENAI_API_BASE = baseUrl; + env.OPENAI_API_KEY = token; + } else if (kind === "goose") { + env.GOOSE_PROVIDER = "openai"; + env.OPENAI_HOST = baseUrl; + env.OPENAI_API_KEY = token; + if (model) env.GOOSE_MODEL = model; + } else if (kind === "opencode") { + env.OMNIROUTE_API_KEY = token; + env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + omniroute: { + npm: "@ai-sdk/openai-compatible", + name: "OmniRoute", + options: { + baseURL: ensureV1BaseUrl(baseUrl), + apiKey: "{env:OMNIROUTE_API_KEY}", + }, + ...(model ? { models: { [model]: { name: model } } } : {}), + }, + }, + }); + } else if (kind === "qwen") { + env.OMNIROUTE_API_KEY = token; + } else if (kind === "gemini") { + // Verified against @google/gemini-cli 0.50.0: the SDK appends + // /v1beta/models/:generateContent to this base URL, which is + // OmniRoute's native Gemini surface. Auth is the API-key path; the + // isolated GEMINI_CLI_HOME (set at spawn time) keeps any stored OAuth + // session from overriding it. + env.GOOGLE_GEMINI_BASE_URL = baseUrl; + env.GEMINI_API_KEY = token; + env.GEMINI_DEFAULT_AUTH_TYPE = "gemini-api-key"; + } + return env; +} + +function ensureV1BaseUrl(baseUrl) { + const normalized = String(baseUrl || "").replace(/\/+$/, ""); + return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`; +} + +function modelArgsForTarget(target, model) { + return manifestModelArgs(target, model); +} + +function buildGeminiSettings() { + // Force API-key auth in the isolated home so the operator's stored OAuth + // session (Code Assist) never leaks into an OmniRoute-directed launch. + return JSON.stringify({ security: { auth: { selectedType: "gemini-api-key" } } }, null, 2); +} + +function buildQwenSettings(baseUrl, model) { + const qwenBaseUrl = ensureV1BaseUrl(baseUrl); + return JSON.stringify( + { + modelProviders: { + openai: [ + { + id: model, + name: `${model} (OmniRoute)`, + envKey: "OMNIROUTE_API_KEY", + baseUrl: qwenBaseUrl, + }, + ], + }, + security: { auth: { selectedType: "openai" } }, + model: { name: model, baseUrl: qwenBaseUrl }, + }, + null, + 2 + ); +} + +async function buildGenericPlan(target, rawOpts, args = []) { + const { baseUrl, authToken } = resolveLaunchTarget({ + ...rawOpts, + apiKey: resolveAuthTokenOption(rawOpts), + }); + const commandSpec = resolveGenericSpawn(target); + const model = resolveModelFromTargetOptions(rawOpts); + if (manifestRequiresModel(target) && !model) { + throw new Error("Qwen Code requires --model in non-interactive OmniRoute launches"); + } + const modelArgs = modelArgsForTarget(target, model); + const fullArgs = [...modelArgs, ...args]; + const env = genericEnv(process.env, target, baseUrl, authToken, model); + + return { + target, + baseUrl, + command: commandSpec.command, + shell: commandSpec.shell, + args: quoteShellArgs(fullArgs, process.platform), + model: model || undefined, + envDiff: envPreview(process.env, env), + authSource: toAuthSource(rawOpts), + commandDisplay: describeCommand(commandSpec.command, commandSpec.shell), + modelArgs, + configOverlay: + target === "qwen" + ? "temporary QWEN_HOME (removed after exit)" + : target === "gemini" + ? "temporary GEMINI_CLI_HOME (removed after exit)" + : target === "opencode" + ? "OPENCODE_CONFIG_CONTENT (process environment only)" + : undefined, + }; +} + +async function healthCheckForRun(baseUrl) { + try { + const response = await fetch(`${baseUrl}/api/monitoring/health`, { + signal: AbortSignal.timeout(3000), + }); + return response.ok; + } catch { + return false; + } +} + +async function runGenericTarget(target, rawOpts, args) { + const { baseUrl, authToken } = resolveLaunchTarget({ + ...rawOpts, + apiKey: resolveAuthTokenOption(rawOpts), + }); + if (!(await healthCheckForRun(baseUrl))) { + console.error(`OmniRoute is not reachable at ${baseUrl}. Start it or check --remote.`); + return 1; + } + + const model = resolveModelFromTargetOptions(rawOpts); + if (manifestRequiresModel(target) && !model) { + console.error("Qwen Code requires --model in non-interactive OmniRoute launches."); + return 2; + } + const modelArgs = modelArgsForTarget(target, model); + const commandSpec = resolveGenericSpawn(target); + const childEnv = genericEnv(process.env, target, baseUrl, authToken, model); + let overlayHome; + if (target === "qwen") { + overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-qwen-run-")); + writeFileSync(join(overlayHome, "settings.json"), buildQwenSettings(baseUrl, model), { + encoding: "utf8", + mode: 0o600, + }); + childEnv.QWEN_HOME = overlayHome; + } else if (target === "gemini") { + overlayHome = mkdtempSync(join(os.tmpdir(), "omniroute-gemini-run-")); + mkdirSync(join(overlayHome, ".gemini"), { recursive: true }); + writeFileSync(join(overlayHome, ".gemini", "settings.json"), buildGeminiSettings(), { + encoding: "utf8", + mode: 0o600, + }); + childEnv.GEMINI_CLI_HOME = overlayHome; + } + + const child = spawn( + commandSpec.command, + quoteShellArgs([...modelArgs, ...args], process.platform), + { + env: childEnv, + stdio: "inherit", + shell: commandSpec.shell, + ...(process.platform === "win32" ? { windowsHide: true } : {}), + } + ); + + const cleanup = () => { + if (!overlayHome) return; + try { + rmSync(overlayHome, { recursive: true, force: true }); + } catch { + // Best-effort cleanup; the directory contains no persistent credentials. + } + }; + + return await new Promise((resolve) => { + let settled = false; + const signalExitCode = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }; + const finish = (code) => { + if (settled) return; + settled = true; + for (const signal of Object.keys(signalExitCode)) { + process.removeListener(signal, signalHandlers[signal]); + } + cleanup(); + resolve(code); + }; + const signalHandlers = {}; + for (const signal of Object.keys(signalExitCode)) { + signalHandlers[signal] = () => { + try { + child.kill(signal); + } catch { + // The child may have already exited between the signal and cleanup. + } + finish(signalExitCode[signal]); + }; + process.once(signal, signalHandlers[signal]); + } + child.on("error", (error) => { + if (error?.code === "ENOENT") { + console.error(`The '${target}' CLI was not found in PATH.`); + finish(127); + } else { + console.error(String(error?.message || error)); + finish(1); + } + }); + child.on("exit", (code, signal) => { + finish(code ?? signalExitCode[signal] ?? 0); + }); + }); +} + /** Build a launch plan and redact any resolved secret values. */ export async function buildRunPlan(target, rawOpts = {}, args = []) { const canonical = resolveRunTarget(target); if (!canonical) { - throw new Error("unsupported target"); + throw new Error( + `Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}` + ); } if (canonical === "claude") { return buildClaudePlan(rawOpts, args); } - return buildCodexPlan(rawOpts, args); + if (canonical === "codex") { + return buildCodexPlan(rawOpts, args); + } + + return buildGenericPlan(canonical, rawOpts, args); } function writeDryRunOutput(plan, opts = {}) { @@ -207,6 +485,7 @@ function writeDryRunOutput(plan, opts = {}) { }, shell: !!plan.shell, model: plan.model || null, + configOverlay: plan.configOverlay || null, env: { changedOrAdded: plan.envDiff.changedOrAdded, removed: plan.envDiff.removed, @@ -224,6 +503,7 @@ function writeDryRunOutput(plan, opts = {}) { console.log(`args: ${JSON.stringify(output.args)}`); console.log(`auth: ${JSON.stringify(output.auth)}`); console.log(`model: ${output.model || "(not set)"}`); + if (output.configOverlay) console.log(`config overlay: ${output.configOverlay}`); if (output.env.changedOrAdded.length) { console.log(`env added/changed: ${output.env.changedOrAdded.join(", ")}`); } @@ -237,8 +517,8 @@ function buildExecutionOptionsForClaude(rawOpts) { return { ...rawOpts, model: resolveModelFromTargetOptions(rawOpts), - token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"], - apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token, + token: resolveAuthTokenOption(rawOpts), + apiKey: resolveAuthTokenOption(rawOpts), profile: rawOpts.profile || rawOpts.p, }; } @@ -247,7 +527,7 @@ function buildExecutionOptionsForCodex(rawOpts) { return { ...rawOpts, model: resolveModelFromTargetOptions(rawOpts), - apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token, + apiKey: resolveAuthTokenOption(rawOpts), profile: rawOpts.profile || rawOpts.p, }; } @@ -262,12 +542,18 @@ export async function runCliTarget(target, opts = {}, args = []) { const canonical = resolveRunTarget(target); if (!canonical) { process.stderr.write( - `Unsupported target '${target}'. Supported targets: ${Object.keys(RUN_TARGETS).join(", ")}\n` + `Unsupported target '${target}'. Supported targets: ${listRunTargets().join(", ")}\n` ); return 2; } - const plan = await buildRunPlan(target, opts, args); + let plan; + try { + plan = await buildRunPlan(target, opts, args); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + return 2; + } if (opts.dryRun) { writeDryRunOutput(plan, opts); @@ -278,7 +564,11 @@ export async function runCliTarget(target, opts = {}, args = []) { return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args); } - return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args); + if (canonical === "codex") { + return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args); + } + + return await runGenericTarget(canonical, opts, args); } export function registerRun(program) { @@ -294,12 +584,15 @@ export function registerRun(program) { "--remote ", "Remote OmniRoute base URL (overrides --port, --base-url, and the active context)" ) + .option("--base-url ", "OmniRoute base URL (alias for --remote)") + .option("--context ", "Named local/remote context to use for URL and credentials") .option("--provider ", "Provider id for shorthand model composition") .option("--model ", "Model id to inject in the launched target where supported") .option("--profile ", "Profile/alias argument for target launchers that support it") .option("-p, --p ", "Alias for --profile") .option("--token ", "Authentication token for the launched target (same as --api-key)") .option("--api-key ", "Authentication token for the launched target") + .option("--api-key-env ", "Read the launch token from an environment variable") .option("--dry-run", "Show planned command and env keys without executing") .option("--json", "Return dry-run output in machine-readable format") .allowUnknownOption(true) diff --git a/bin/cli/commands/setup.mjs b/bin/cli/commands/setup.mjs index d80777c0c6..4ded5032d4 100644 --- a/bin/cli/commands/setup.mjs +++ b/bin/cli/commands/setup.mjs @@ -133,6 +133,29 @@ async function setupProvider(db, opts, prompt, nonInteractive) { return connection; } +/** + * Merge the `setup` subcommand options with the program-level ones. + * + * The program declares a global `--api-key` (the OmniRoute *server* key, see + * bin/cli/program.mjs) and `setup` declares its own `--api-key` (the *provider* + * key). Commander binds the value to the program-level option, so the + * subcommand's `opts.apiKey` is always `undefined` and `--add-provider` failed + * with "Provider API key is required" even when `--api-key` was passed. Falling + * back to the global value also makes `OMNIROUTE_API_KEY` work, which the error + * message already told users to use. + * + * @param {Record} opts Subcommand options. + * @param {Record} globalOpts Result of `cmd.optsWithGlobals()`. + * @returns {Record} Options to hand to `runSetupCommand`. + */ +export function mergeSetupOptions(opts, globalOpts) { + return { + ...opts, + apiKey: opts.apiKey ?? globalOpts.apiKey, + output: globalOpts.output, + }; +} + export function registerSetup(program) { program .command("setup") @@ -149,7 +172,7 @@ export function registerSetup(program) { .option("--list", "List all supported CLI tools") .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); - const exitCode = await runSetupCommand({ ...opts, output: globalOpts.output }); + const exitCode = await runSetupCommand(mergeSetupOptions(opts, globalOpts)); if (exitCode !== 0) process.exit(exitCode); }); diff --git a/bin/cli/commands/test-provider.mjs b/bin/cli/commands/test-provider.mjs index 4c45e81f6a..8802f75cd1 100644 --- a/bin/cli/commands/test-provider.mjs +++ b/bin/cli/commands/test-provider.mjs @@ -80,7 +80,7 @@ async function _runAllProviders(opts) { return 1; } const data = await res.json(); - const connections = (data.providers ?? data.items ?? data).filter( + const connections = (data.connections ?? data.providers ?? data.items ?? data).filter( (c) => c.authType === "apikey" || c.testStatus !== "unavailable" ); if (connections.length === 0) { diff --git a/bin/cli/contexts.mjs b/bin/cli/contexts.mjs index 2a691a1ef9..c02731da3f 100644 --- a/bin/cli/contexts.mjs +++ b/bin/cli/contexts.mjs @@ -3,6 +3,108 @@ import { join, dirname } from "node:path"; import { resolveDataDir } from "./data-dir.mjs"; const CONFIG_VERSION = 1; +const KEYCHAIN_SERVICE = "omniroute-cli"; +const KEYCHAIN_DISABLED = /^(1|true|yes|on)$/i.test( + String(process.env.OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED || "") +); + +// `keytar` is optional and native. Keeping it behind a small interface lets +// headless installs use the same CLI without requiring libsecret/Keychain at +// install time, while tests can inject a deterministic fake backend. +let keychainBackend = null; +let keychainOperational = true; +let warnedPlaintextFallback = false; +const credentialCache = new Map(); + +function isKeychainBackend(value) { + return ( + value && + typeof value.getPassword === "function" && + typeof value.setPassword === "function" && + typeof value.deletePassword === "function" + ); +} + +async function loadKeychainBackend() { + if (KEYCHAIN_DISABLED) return null; + try { + const imported = await import("keytar"); + const candidate = isKeychainBackend(imported?.default) ? imported.default : imported; + return isKeychainBackend(candidate) ? candidate : null; + } catch { + // Native keychain modules are optional and commonly unavailable in + // containers. The secure file fallback is handled explicitly below. + return null; + } +} + +function parseCredential(value) { + if (!value || typeof value !== "string") return null; + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; + const result = {}; + if (typeof parsed.accessToken === "string" && parsed.accessToken) { + result.accessToken = parsed.accessToken; + } + if (typeof parsed.apiKey === "string" && parsed.apiKey) result.apiKey = parsed.apiKey; + return result.accessToken || result.apiKey ? result : null; + } catch { + // Older/externally managed entries may contain one raw token. + return { accessToken: value }; + } +} + +function credentialForContext(context) { + const ref = context && typeof context.credentialRef === "string" ? context.credentialRef : ""; + return ref ? credentialCache.get(ref) || null : null; +} + +function applyCachedCredential(context) { + const cached = credentialForContext(context); + if (!cached) return { ...context }; + return { ...context, ...cached }; +} + +async function hydrateCredentialCache(cfg) { + if (!keychainBackend || !keychainOperational) return; + const contexts = cfg?.contexts || cfg?.profiles || {}; + for (const context of Object.values(contexts)) { + const ref = context && typeof context === "object" ? context.credentialRef : null; + if (!ref || credentialCache.has(ref)) continue; + try { + const parsed = parseCredential(await keychainBackend.getPassword(KEYCHAIN_SERVICE, ref)); + if (parsed) credentialCache.set(ref, parsed); + } catch { + keychainOperational = false; + break; + } + } +} + +function warnPlaintextFallback() { + if (warnedPlaintextFallback) return; + warnedPlaintextFallback = true; + process.stderr.write( + "Warning: OS keychain unavailable; context credentials use config.json mode 0600 fallback.\n" + ); +} + +function readConfigFile() { + try { + if (!existsSync(configPath())) return defaultConfig(); + const parsed = JSON.parse(readFileSync(configPath(), "utf8")); + return parsed && typeof parsed === "object" ? parsed : defaultConfig(); + } catch { + return defaultConfig(); + } +} + +// Resolve keychain state before importing commands can call the synchronous +// compatibility helpers below. Credentials themselves stay in memory; only a +// stable reference is persisted in config.json when keytar is available. +keychainBackend = await loadKeychainBackend(); +await hydrateCredentialCache(readConfigFile()); export function configPath() { return join(resolveDataDir(), "config.json"); @@ -19,14 +121,13 @@ function defaultConfig() { } export function loadContexts() { - try { - if (!existsSync(configPath())) return defaultConfig(); - return JSON.parse(readFileSync(configPath(), "utf8")); - } catch { - return defaultConfig(); - } + return readConfigFile(); } +/** + * Synchronous compatibility writer. New credential-bearing code should use + * `saveContextsSecure()` so tokens are moved to the OS keychain when possible. + */ export function saveContexts(cfg) { const path = configPath(); mkdirSync(dirname(path), { recursive: true }); @@ -36,6 +137,116 @@ export function saveContexts(cfg) { } catch {} } +/** Stable keychain reference; the reference itself is safe to persist in JSON. */ +export function contextCredentialRef(name) { + return `${KEYCHAIN_SERVICE}:context:${encodeURIComponent(String(name))}`; +} + +/** Expose a non-secret capability status for diagnostics and tests. */ +export function getContextKeychainStatus() { + return { + available: Boolean(keychainBackend && keychainOperational), + disabled: KEYCHAIN_DISABLED, + fallback: !keychainBackend || !keychainOperational, + }; +} + +/** + * Store context credentials through keytar and write only a credentialRef to + * config.json. If keytar cannot be used, preserve the credential in the + * mode-0600 file and emit one explicit warning instead of breaking headless + * installs. + */ +export async function saveContextsSecure(cfg) { + const source = cfg && typeof cfg === "object" ? cfg : defaultConfig(); + const next = JSON.parse(JSON.stringify(source)); + next.version = next.version || CONFIG_VERSION; + if (!next.contexts && next.profiles) { + next.contexts = next.profiles; + delete next.profiles; + } + next.contexts = next.contexts || {}; + + for (const [name, raw] of Object.entries(next.contexts)) { + const context = raw && typeof raw === "object" ? raw : {}; + const accessToken = typeof context.accessToken === "string" ? context.accessToken : ""; + const apiKey = typeof context.apiKey === "string" ? context.apiKey : ""; + const hasCredential = Boolean(accessToken || apiKey); + + if (hasCredential && keychainBackend && keychainOperational) { + const ref = + typeof context.credentialRef === "string" && context.credentialRef + ? context.credentialRef + : contextCredentialRef(name); + try { + await keychainBackend.setPassword( + KEYCHAIN_SERVICE, + ref, + JSON.stringify({ + ...(accessToken ? { accessToken } : {}), + ...(apiKey ? { apiKey } : {}), + }) + ); + credentialCache.set(ref, { + ...(accessToken ? { accessToken } : {}), + ...(apiKey ? { apiKey } : {}), + }); + context.credentialRef = ref; + delete context.accessToken; + delete context.apiKey; + } catch { + keychainOperational = false; + warnPlaintextFallback(); + } + } else if (hasCredential) { + warnPlaintextFallback(); + } + + next.contexts[name] = context; + } + + saveContexts(next); + return { + usedKeychain: Boolean(keychainBackend && keychainOperational), + config: next, + }; +} + +/** Remove the keychain entry associated with a context, if one exists. */ +export async function deleteContextCredential(name, context) { + const cfg = loadContexts(); + const candidate = context || cfg.contexts?.[name] || cfg.profiles?.[name] || {}; + const ref = candidate.credentialRef || contextCredentialRef(name); + credentialCache.delete(ref); + if (!keychainBackend || !keychainOperational) return false; + try { + await keychainBackend.deletePassword(KEYCHAIN_SERVICE, ref); + return true; + } catch { + keychainOperational = false; + return false; + } +} + +/** Explicitly migrate legacy plaintext context credentials. */ +export async function migrateContextCredentials() { + const cfg = loadContexts(); + const pending = Object.values(cfg.contexts || cfg.profiles || {}).some( + (context) => context?.accessToken || context?.apiKey + ); + if (!pending) return { migrated: false, pending: false, ...getContextKeychainStatus() }; + const result = await saveContextsSecure(cfg); + return { migrated: result.usedKeychain, pending: true, ...getContextKeychainStatus() }; +} + +/** Test-only backend injection; no secret is returned by this function. */ +export async function setContextKeychainBackendForTests(backend) { + keychainBackend = isKeychainBackend(backend) ? backend : null; + keychainOperational = true; + credentialCache.clear(); + await hydrateCredentialCache(readConfigFile()); +} + /** * Resolve the active context for a CLI invocation. * @@ -54,7 +265,13 @@ export function resolveActiveContext(overrideName) { const contexts = cfg.contexts || cfg.profiles || {}; const name = overrideName || cfg.currentContext || cfg.activeProfile || "default"; const found = contexts[name] || contexts.default; - if (found) return found; + if (found) return applyCachedCredential(found); if (cfg.baseUrl) return { baseUrl: cfg.baseUrl }; return { baseUrl: `http://localhost:${process.env.PORT || "20128"}` }; } + +/** Async variant for callers that need to observe a just-created keychain entry. */ +export async function resolveActiveContextAsync(overrideName) { + await hydrateCredentialCache(readConfigFile()); + return resolveActiveContext(overrideName); +} diff --git a/bin/cli/locales/en.json b/bin/cli/locales/en.json index 9783cb2c79..442df57300 100644 --- a/bin/cli/locales/en.json +++ b/bin/cli/locales/en.json @@ -1300,7 +1300,7 @@ "description": "Manage scoped CLI access tokens (remote mode)" }, "configure": { - "description": "Pick a provider+model from the active server and write a local CLI config" + "description": "Pick a provider+model from the active server and configure a supported local CLI" }, "launchCodex": { "description": "Launch Codex CLI pointed at OmniRoute (local or remote VPS)" diff --git a/bin/cli/locales/pt-BR.json b/bin/cli/locales/pt-BR.json index 4eb984f4d0..c821bf976c 100644 --- a/bin/cli/locales/pt-BR.json +++ b/bin/cli/locales/pt-BR.json @@ -1297,7 +1297,7 @@ "description": "Gerencia tokens de acesso CLI com escopo (modo remoto)" }, "configure": { - "description": "Escolhe um provedor+modelo do servidor ativo e grava uma configuração de CLI local" + "description": "Escolhe um provedor+modelo do servidor ativo e configura uma CLI local compatível" }, "launchCodex": { "description": "Inicia o Codex CLI apontando para o OmniRoute (local ou VPS remoto)" diff --git a/bin/cli/model-preferences.mjs b/bin/cli/model-preferences.mjs new file mode 100644 index 0000000000..f388eb61a6 --- /dev/null +++ b/bin/cli/model-preferences.mjs @@ -0,0 +1,109 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { resolveDataDir } from "./data-dir.mjs"; + +const PREFERENCES_VERSION = 1; +const MAX_RECENT = 12; +const MAX_FAVORITES = 32; + +export function modelPreferencesPath() { + return join(resolveDataDir(), "model-preferences.json"); +} + +function defaultPreferences() { + return { version: PREFERENCES_VERSION, targets: {}, contexts: {} }; +} + +export function loadModelPreferences() { + try { + const path = modelPreferencesPath(); + if (!existsSync(path)) return defaultPreferences(); + const parsed = JSON.parse(readFileSync(path, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return defaultPreferences(); + } + return { + version: PREFERENCES_VERSION, + targets: parsed.targets && typeof parsed.targets === "object" ? parsed.targets : {}, + contexts: parsed.contexts && typeof parsed.contexts === "object" ? parsed.contexts : {}, + }; + } catch { + return defaultPreferences(); + } +} + +function saveModelPreferences(preferences) { + const path = modelPreferencesPath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(preferences, null, 2)); + try { + chmodSync(path, 0o600); + } catch { + // Best effort on platforms without POSIX modes. + } +} + +function normalizeIds(values) { + return [...new Set((Array.isArray(values) ? values : []).filter((id) => typeof id === "string"))]; +} + +function targetState(preferences, target, contextKey) { + const raw = contextKey + ? preferences.contexts?.[contextKey]?.[target] || + (contextKey === "default" ? preferences.targets?.[target] : undefined) + : preferences.targets?.[target]; + return { + favorites: normalizeIds(raw?.favorites), + recent: normalizeIds(raw?.recent), + }; +} + +function writeTargetState(preferences, target, contextKey) { + if (!contextKey) { + preferences.targets[target] = targetState(preferences, target); + return preferences.targets[target]; + } + preferences.contexts = preferences.contexts || {}; + preferences.contexts[contextKey] = preferences.contexts[contextKey] || {}; + preferences.contexts[contextKey][target] = targetState(preferences, target, contextKey); + return preferences.contexts[contextKey][target]; +} + +/** Rank catalog IDs with favorites first, then recent choices, then catalog order. */ +export function rankPreferredModels( + target, + modelIds, + preferences = loadModelPreferences(), + contextKey = "" +) { + const ids = normalizeIds(modelIds); + const state = targetState(preferences, target, contextKey); + const available = new Set(ids); + const preferred = [...state.favorites, ...state.recent].filter((id) => available.has(id)); + return [...new Set([...preferred, ...ids])]; +} + +/** Record a successful selection without storing server URLs or credentials. */ +export function recordModelPreference(target, modelId, options = {}) { + if (!target || !modelId) return loadModelPreferences(); + const preferences = loadModelPreferences(); + const state = writeTargetState(preferences, target, options.context || ""); + state.recent = [modelId, ...state.recent.filter((id) => id !== modelId)].slice(0, MAX_RECENT); + if (options.favorite) { + state.favorites = [modelId, ...state.favorites.filter((id) => id !== modelId)].slice( + 0, + MAX_FAVORITES + ); + } + if (options.unfavorite) state.favorites = state.favorites.filter((id) => id !== modelId); + saveModelPreferences(preferences); + return preferences; +} + +export function getModelPreferenceState( + target, + preferences = loadModelPreferences(), + contextKey = "" +) { + return targetState(preferences, target, contextKey); +} diff --git a/bin/cli/utils/cliToken.mjs b/bin/cli/utils/cliToken.mjs index da504019a3..94691ba952 100644 --- a/bin/cli/utils/cliToken.mjs +++ b/bin/cli/utils/cliToken.mjs @@ -1,22 +1,39 @@ import crypto from "node:crypto"; -const SALT = "omniroute-cli-auth-v1"; +const BUILTIN_DEFAULT_SALT = "omniroute-cli-auth-v1"; export const CLI_TOKEN_HEADER = "x-omniroute-cli-token"; let _cached = null; +let _cachedSalt = null; + +/** Mirrors getActiveSalt() in src/lib/machineToken.ts so a rotated + * OMNIROUTE_CLI_SALT reaches the CLI too (docs/security/CLI_TOKEN.md). */ +function getActiveSalt() { + return process.env.OMNIROUTE_CLI_SALT || BUILTIN_DEFAULT_SALT; +} export async function getCliToken() { - if (_cached !== null) return _cached; + const salt = getActiveSalt(); + if (_cached !== null && _cachedSalt === salt) return _cached; try { - const { machineIdSync } = await import("node-machine-id"); - const mid = machineIdSync(); - _cached = crypto - .createHash("sha256") - .update(mid + SALT) - .digest("hex") - .substring(0, 32); - } catch { + // node-machine-id is CommonJS: under `await import()` its exports land on + // `.default`, so destructuring `machineIdSync` off the namespace yields + // undefined and calling it throws — which the catch below turned into an + // empty token, silently disabling CLI auth for every management request. + // Same resolution order as src/lib/machineToken.ts. + const mod = await import("node-machine-id"); + const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync; + if (typeof machineIdSync !== "function") throw new Error("machine-id API unavailable"); + // machineIdSync(true) returns the original unhashed hardware ID — mirrors + // getMachineTokenSync() in src/lib/machineToken.ts (#10148 cliToken hardening). + const mid = machineIdSync(true); + _cached = crypto.createHmac("sha256", mid).update(salt).digest("hex"); + } catch (e) { + // Swallowing here changes control flow (every management call goes out + // unauthenticated and 401s), so leave a breadcrumb rather than failing mute. + console.debug("[CLI_TOKEN] machine-id resolution failed, CLI auth disabled:", e); _cached = ""; } + _cachedSalt = salt; return _cached; } diff --git a/changelog.d/features/10039-combo-lane-awareness-wave-2.md b/changelog.d/features/10039-combo-lane-awareness-wave-2.md new file mode 100644 index 0000000000..7c8cba55ba --- /dev/null +++ b/changelog.d/features/10039-combo-lane-awareness-wave-2.md @@ -0,0 +1,2 @@ +- **feat(admission):** add lane-aware admission probes for combo/fusion/chaos fan-out (fail-open, queueing disabled), an env-wins `OMNIROUTE_CHAT_VIRTUAL_LANES` activation flag applied at boot, and adaptive-lane visibility in the `omniroute_get_health` MCP tool (related to #9654) +- **docs(mcp):** complete the MCP server README tool reference so the `schemas/` catalog is fully covered (agent-skills, oneproxy, web, tool-search, combo/routing, pricing and DB-health tools were previously only discoverable via `omniroute_tool_search`) diff --git a/changelog.d/features/10389-cloudflare-playground.md b/changelog.d/features/10389-cloudflare-playground.md new file mode 100644 index 0000000000..fb6bd80c0a --- /dev/null +++ b/changelog.d/features/10389-cloudflare-playground.md @@ -0,0 +1 @@ +- feat(providers): add **Cloudflare AI Playground** as a No Auth provider (`cloudflare-playground`, alias `cfp`) — free anonymous chat over the reverse-engineered `cf_agent` WebSocket protocol (PartySocket transport, no account/API key/cookies) with GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, gpt-oss-120B, Llama 3.3 70B, Qwen2.5 Coder 32B and 14 more curated models. The executor drives a headless Chromium via Playwright (the WS upgrade is TLS-fingerprint-gated), translates the `cf_agent` frame stream into OpenAI SSE, and surfaces upstream rate limits (3021) as HTTP 429. Fixes #10389 diff --git a/changelog.d/features/10542-aihorde-optional-key-image-catalog.md b/changelog.d/features/10542-aihorde-optional-key-image-catalog.md new file mode 100644 index 0000000000..4a8f67b766 --- /dev/null +++ b/changelog.d/features/10542-aihorde-optional-key-image-catalog.md @@ -0,0 +1,2 @@ +- **feat(providers):** AI Horde accepts an optional registered API key and advertises only live image models that currently have workers ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542)) +- **fix(providers):** AI Horde Check validates keys via `/v2/find_user` instead of the unauthenticated OpenAI models list ([#10542](https://github.com/diegosouzapw/OmniRoute/pull/10542)) diff --git a/changelog.d/features/10581-jina-complete-provider.md b/changelog.d/features/10581-jina-complete-provider.md new file mode 100644 index 0000000000..d4fc0424a3 --- /dev/null +++ b/changelog.d/features/10581-jina-complete-provider.md @@ -0,0 +1 @@ +- **feat(providers):** complete Jina AI as one credential pool — dashboard `jina-ai` / `jina-reader` share a token, `JINA_AI_API_KEY` is a real fallback, Test probes `GET https://api.jina.ai/v1/models` (embeddings fallback hits `jina-embeddings-v5-omni-small`), embed/rerank logs keep `connection_id`, catalog adds `jina-reranker-v3.5`, Omni v5 multimodal `{text}`/`{image}`/`{content}` docs pass through intact, and OmniRoute proxies classify / segment / `jina-search` (`s.jina.ai`). Reader stays a separate `r.jina.ai` card with an explicit label. Gemini Embedding 2 (`gemini/gemini-embedding-2`, alias `google/gemini-embedding-2`) uses dashboard `gemini` keys (or `GEMINI_API_KEY` / `GOOGLE_API_KEY` only when none exist), forwards native multimodal parts, and maps N OpenAI `input` items to N `:batchEmbedContents` vectors instead of one aggregated `:embedContent`. ([#10581](https://github.com/diegosouzapw/OmniRoute/pull/10581)) diff --git a/changelog.d/features/10617-auto-disable-banned-scope.md b/changelog.d/features/10617-auto-disable-banned-scope.md new file mode 100644 index 0000000000..e1fc1705a8 --- /dev/null +++ b/changelog.d/features/10617-auto-disable-banned-scope.md @@ -0,0 +1 @@ +- **feat(settings):** add `autoDisableBannedScope` so permanent-ban auto-disable can target subscription/OAuth accounts only, leaving prepaid API keys in the routing pool ([#10617](https://github.com/diegosouzapw/OmniRoute/pull/10617)) diff --git a/changelog.d/features/multimodal-embeddings-alias.md b/changelog.d/features/multimodal-embeddings-alias.md new file mode 100644 index 0000000000..b69c53ecb5 --- /dev/null +++ b/changelog.d/features/multimodal-embeddings-alias.md @@ -0,0 +1 @@ +- **feat(api):** add `GET`/`POST` `/v1/multimodal-embeddings` as an alias of `/v1/embeddings` so Jina-compatible clients do not receive HTTP 404 `unknown_route` — thanks @RaviTharuma diff --git a/changelog.d/features/unreleased-exclusive-managed-session-leases.md b/changelog.d/features/unreleased-exclusive-managed-session-leases.md new file mode 100644 index 0000000000..9db23724ef --- /dev/null +++ b/changelog.d/features/unreleased-exclusive-managed-session-leases.md @@ -0,0 +1 @@ +- **feat(routing):** add client-, provider-, and model-neutral exclusive managed session connection leases with API-key-bound generation fencing, durable SQLite ownership, explicit allowlist policy, and bounded 429 capacity retry semantics. diff --git a/changelog.d/fixes/10017-sse-control-lines-leak-openai-clients.md b/changelog.d/fixes/10017-sse-control-lines-leak-openai-clients.md new file mode 100644 index 0000000000..3c129442b4 --- /dev/null +++ b/changelog.d/fixes/10017-sse-control-lines-leak-openai-clients.md @@ -0,0 +1 @@ +- **Passthrough streaming:** stop leaking upstream SSE control lines (`id:`/`event:`/`retry:`/`:` comments) to plain OpenAI Chat-Completions-format clients, while preserving `event:` framing for OpenAI Responses API and Claude Messages API passthrough ([#10017](https://github.com/diegosouzapw/OmniRoute/issues/10017)). diff --git a/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md b/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md new file mode 100644 index 0000000000..b67fcc0f62 --- /dev/null +++ b/changelog.d/fixes/10078-agentrouter-quota-missing-dashboard.md @@ -0,0 +1,2 @@ +- Fix: wire AgentRouter's existing console balance fetcher into the Dashboard Quota UI (visibility gate + provider-limits data path + background sync) so its wallet balance renders instead of falling back to "Usage API not implemented" (#10078) +- Fix: AgentRouter's dollar balance now renders as a currency-formatted "$X.XX" credits row in the Dashboard Quota UI instead of a bare percentage, and an exhausted wallet always shows exactly $0.00 (#10078) \ No newline at end of file diff --git a/changelog.d/fixes/10085-compatible-chat-credential-mismatch.md b/changelog.d/fixes/10085-compatible-chat-credential-mismatch.md new file mode 100644 index 0000000000..773d4ed3cb --- /dev/null +++ b/changelog.d/fixes/10085-compatible-chat-credential-mismatch.md @@ -0,0 +1 @@ +- fix(sse): bridge generic openai-compatible/anthropic-compatible provider type ids to their concrete uuid node id in credential lookup (#10085) diff --git a/changelog.d/fixes/10096-kimi-coding-apikey-save.md b/changelog.d/fixes/10096-kimi-coding-apikey-save.md new file mode 100644 index 0000000000..2b5f1bb8b6 --- /dev/null +++ b/changelog.d/fixes/10096-kimi-coding-apikey-save.md @@ -0,0 +1 @@ +- fix(dashboard): remap unified Kimi Code card API-key save to the admitted `kimi-coding-apikey` connection id, fixing 400 "Invalid provider" on Save (#10096) diff --git a/changelog.d/fixes/10104-antigravity-trailing-model-turn.md b/changelog.d/fixes/10104-antigravity-trailing-model-turn.md new file mode 100644 index 0000000000..80af15279f --- /dev/null +++ b/changelog.d/fixes/10104-antigravity-trailing-model-turn.md @@ -0,0 +1 @@ +- fix(antigravity): strip trailing model turn for native Gemini requests too, not just Claude (#10104) diff --git a/changelog.d/fixes/10111-adaptive-admission-latency-collapse.md b/changelog.d/fixes/10111-adaptive-admission-latency-collapse.md new file mode 100644 index 0000000000..1b806d53e6 --- /dev/null +++ b/changelog.d/fixes/10111-adaptive-admission-latency-collapse.md @@ -0,0 +1 @@ +- **fix(admission):** stop the adaptive latency-gradient collapse from permanently locking out ordinary requests — individually valid requests now make solo progress when the system is idle and normal pressure, and the collapsed limit actively recovers on sustained idle windows instead of being stuck; the critical-pressure fuse still wins over solo progress (#10111) \ No newline at end of file diff --git a/changelog.d/fixes/10119-claude-haiku-45-capability-flags.md b/changelog.d/fixes/10119-claude-haiku-45-capability-flags.md new file mode 100644 index 0000000000..799486ffb0 --- /dev/null +++ b/changelog.d/fixes/10119-claude-haiku-45-capability-flags.md @@ -0,0 +1 @@ +- fix(sse): downgrade client-supplied `thinking:{type:"adaptive"}` to `enabled` and gate the `context-1m-2025-08-07` beta on model eligibility when a combo/fallback re-routes a request to a non-adaptive/non-1M model like claude-haiku-4-5 (avoids "adaptive thinking is not supported on this model" and "long context beta is not yet available" 400s, #10119) \ No newline at end of file diff --git a/changelog.d/fixes/10123-async-call-log-artifacts.md b/changelog.d/fixes/10123-async-call-log-artifacts.md new file mode 100644 index 0000000000..60afcde1cc --- /dev/null +++ b/changelog.d/fixes/10123-async-call-log-artifacts.md @@ -0,0 +1 @@ +- **fix(logging):** move call-log artifact serialization and filesystem writes to a bounded singleton worker to keep request handling responsive (#10123) diff --git a/changelog.d/fixes/10158-local-proxy-subscription.md b/changelog.d/fixes/10158-local-proxy-subscription.md new file mode 100644 index 0000000000..76194c6d49 --- /dev/null +++ b/changelog.d/fixes/10158-local-proxy-subscription.md @@ -0,0 +1 @@ +- fix(proxy-subscriptions): allow local/loopback proxy-subscription fetch URLs (local-first, cloud-metadata still blocked) (#10158) diff --git a/changelog.d/fixes/10171-instrumentation-hook-boot-fatal-log.md b/changelog.d/fixes/10171-instrumentation-hook-boot-fatal-log.md new file mode 100644 index 0000000000..3f2c0fb028 --- /dev/null +++ b/changelog.d/fixes/10171-instrumentation-hook-boot-fatal-log.md @@ -0,0 +1 @@ +- fix(cli): guarantee a non-empty `[STARTUP] Fatal:` log line for any instrumentation-hook boot throw, not just DB-driver init failures (#10171) diff --git a/changelog.d/fixes/10183-10268-admission-heap-conditional-shed.md b/changelog.d/fixes/10183-10268-admission-heap-conditional-shed.md new file mode 100644 index 0000000000..f99bba5035 --- /dev/null +++ b/changelog.d/fixes/10183-10268-admission-heap-conditional-shed.md @@ -0,0 +1 @@ +- fix(sse): gate structural chat admission shedding on real heap pressure instead of unconditional capacity, with a bounded headroom budget so a healthy heap can no longer bypass admission control indefinitely (#10183, #10268) diff --git a/changelog.d/fixes/10225-combo-context-overflow-before-compression.md b/changelog.d/fixes/10225-combo-context-overflow-before-compression.md new file mode 100644 index 0000000000..0a678180af --- /dev/null +++ b/changelog.d/fixes/10225-combo-context-overflow-before-compression.md @@ -0,0 +1 @@ +- **fix(combo):** defer the known-context-overflow hard rejection for compressible requests so compression runs before the final context gate, instead of a raw-body estimate 400'ing generic Responses clients targeting a large model before OmniRoute can shrink it ([#10225](https://github.com/diegosouzapw/OmniRoute/issues/10225)) \ No newline at end of file diff --git a/changelog.d/fixes/10244-cliproxy-installer-windows-platform-detection.md b/changelog.d/fixes/10244-cliproxy-installer-windows-platform-detection.md new file mode 100644 index 0000000000..bdc134b990 --- /dev/null +++ b/changelog.d/fixes/10244-cliproxy-installer-windows-platform-detection.md @@ -0,0 +1 @@ +- **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244) \ No newline at end of file diff --git a/changelog.d/fixes/10249-dedup-hash-collision.md b/changelog.d/fixes/10249-dedup-hash-collision.md new file mode 100644 index 0000000000..f118196dfe --- /dev/null +++ b/changelog.d/fixes/10249-dedup-hash-collision.md @@ -0,0 +1 @@ +- fix(open-sse): stop concurrent requests colliding on the same dedup hash for non-OpenAI target formats (#10249) diff --git a/changelog.d/fixes/10251-text-tool-call-parsing.md b/changelog.d/fixes/10251-text-tool-call-parsing.md new file mode 100644 index 0000000000..9febe54687 --- /dev/null +++ b/changelog.d/fixes/10251-text-tool-call-parsing.md @@ -0,0 +1 @@ +- **fix(translator):** Text-format tool calls emitted inline by some models are now converted to proper `tool_use` blocks. Certain models (DeepSeek, Qwen) return tool invocations as `{"name":"Bash","arguments":{…}}` or `TOOL_CALL Read: {"file_path":"…"}` inside the text stream instead of the structured `tool_calls` field. Both formats leaked through the Claude translators as plain text, so Claude Code rendered the raw block and stalled instead of executing the tool. `extractXmlInvokeBlocks` (previously ``-only) now scans for all three shapes in a single pass and emits `content_block_start`/`input_json_delta`/`content_block_stop` events, in both `openai-to-claude` and `gemini-to-claude` (Antigravity) paths ([#10251](https://github.com/diegosouzapw/OmniRoute/pull/10251)) diff --git a/changelog.d/fixes/10261-provider-warning-badges.md b/changelog.d/fixes/10261-provider-warning-badges.md new file mode 100644 index 0000000000..39720b4bf5 --- /dev/null +++ b/changelog.d/fixes/10261-provider-warning-badges.md @@ -0,0 +1 @@ +- fix(dashboard): make provider card warning indicators expose the interaction they advertise (#10261) diff --git a/changelog.d/fixes/10311-healthcheck-lifecycle-default.md b/changelog.d/fixes/10311-healthcheck-lifecycle-default.md new file mode 100644 index 0000000000..8a27b45d5d --- /dev/null +++ b/changelog.d/fixes/10311-healthcheck-lifecycle-default.md @@ -0,0 +1 @@ +- **fix(ops):** Docker HEALTHCHECK defaults to the lightweight `/healthz` lifecycle probe instead of the heavy `/api/monitoring/health` path, with an `OMNIROUTE_HEALTHCHECK_PATH` opt-in override ([#10311](https://github.com/diegosouzapw/OmniRoute/pull/10311)) \ No newline at end of file diff --git a/changelog.d/fixes/10314-combo-error-aggregation.md b/changelog.d/fixes/10314-combo-error-aggregation.md new file mode 100644 index 0000000000..7dd3ef6a60 --- /dev/null +++ b/changelog.d/fixes/10314-combo-error-aggregation.md @@ -0,0 +1 @@ +- fix(resilience): keep combo quality and auth failure reasons separate and redact connection labels in terminal errors (#10314) diff --git a/changelog.d/fixes/10319-live-ws-heartbeat-ping.md b/changelog.d/fixes/10319-live-ws-heartbeat-ping.md new file mode 100644 index 0000000000..91ed7b00c5 --- /dev/null +++ b/changelog.d/fixes/10319-live-ws-heartbeat-ping.md @@ -0,0 +1 @@ +- fix(dashboard): send periodic WS heartbeat pings so live dashboard connections stop dropping every ~35s (#10319) diff --git a/changelog.d/fixes/10365-gitlab-duo-401-fallback.md b/changelog.d/fixes/10365-gitlab-duo-401-fallback.md new file mode 100644 index 0000000000..cc05612a00 --- /dev/null +++ b/changelog.d/fixes/10365-gitlab-duo-401-fallback.md @@ -0,0 +1 @@ +- fix(providers): GitLab Duo falls back to the public Code Suggestions endpoint when direct_access returns 401 (#10365) \ No newline at end of file diff --git a/changelog.d/fixes/10374-claude-tool-name-casing-normalization.md b/changelog.d/fixes/10374-claude-tool-name-casing-normalization.md new file mode 100644 index 0000000000..9acf0e08c7 --- /dev/null +++ b/changelog.d/fixes/10374-claude-tool-name-casing-normalization.md @@ -0,0 +1 @@ +- **fix(translator):** Consolidate tool-name casing normalization into a single `restoreClaudeToolName` helper reused across every response path (`openai-to-claude`, `gemini-to-claude`, `stream` passthrough, xAI and Antigravity handlers), replacing six hand-copied 7-entry casing maps. The shared helper resolves via the request-side `toolNameMap` first (preserving declared PascalCase and MCP/alias names), then the complete `TOOL_RENAME_MAP` (which already covers `glob`/`grep`/`task`/`todowrite`/`skill`/`askuserquestion`/etc.), then the `#7926` TitleCase→lowercase fallback for map-less clients. This closes the coverage gap that left `TodoWrite` and other tools failing with `Error: No such tool available: todowrite`, fixes a `ReferenceError` in `remapToolNamesInResponse`, and preserves the Gemini thought-signature persistence (`#8979`) and OpenAI→Claude `toolNameMap` restoration that must not regress ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374)) diff --git a/changelog.d/fixes/10374-openai-compatible-responses-passthrough.md b/changelog.d/fixes/10374-openai-compatible-responses-passthrough.md new file mode 100644 index 0000000000..d735feed1c --- /dev/null +++ b/changelog.d/fixes/10374-openai-compatible-responses-passthrough.md @@ -0,0 +1 @@ +- **fix(responses):** preserve native tool definitions for custom OpenAI-compatible providers when using the Responses API (`/v1/responses`). When `apiType` is set to `"responses"` (or `_omnirouteForceResponsesUpstream` is enabled), OmniRoute passes native tool shapes (`custom` with lark grammars, `namespace`, `local_shell`) directly upstream without running a lossy Responses→Chat→Responses conversion ([#10374](https://github.com/diegosouzapw/OmniRoute/issues/10374)) diff --git a/changelog.d/fixes/10381-free-tier-usage-history.md b/changelog.d/fixes/10381-free-tier-usage-history.md new file mode 100644 index 0000000000..4009855cc0 --- /dev/null +++ b/changelog.d/fixes/10381-free-tier-usage-history.md @@ -0,0 +1 @@ +- fix(dashboard): Free Tier 'used this month' now includes live usage_history rows, not just the rolled-up daily summary (#10381) diff --git a/changelog.d/fixes/10489-qdrant-health-badge.md b/changelog.d/fixes/10489-qdrant-health-badge.md new file mode 100644 index 0000000000..f9c216e8a5 --- /dev/null +++ b/changelog.d/fixes/10489-qdrant-health-badge.md @@ -0,0 +1,2 @@ +- **fix(memory):** auto-check Qdrant health on mount and stop the false-red status badge on `/dashboard/memory?tab=engine` — the badge treated "not yet checked" (`health === null`) as a failure, so a healthy Qdrant showed red after every page refresh until "Test connection" was clicked; settings changes now also invalidate the stale result and re-check after the save persists, so a health check racing the settings PUT can no longer keep the badge red until a manual re-test ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) +- **test(compression):** align source-contract tests with the merged `release/v3.8.50` base (`aa912c42a`) — accept the multi-line `providerTransport` shape in `omniglyph-chatcore-plumbing` and give the pipeline-circuit-breaker fixture a `metadata.executionStages` (both structural changes landed in the base merge) ([#10489](https://github.com/diegosouzapw/OmniRoute/pull/10489)) diff --git a/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md b/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md new file mode 100644 index 0000000000..af2a0d6b4c --- /dev/null +++ b/changelog.d/fixes/10517-zed-hosted-oauth-callback-port.md @@ -0,0 +1 @@ +- **fix(providers):** zed-hosted OAuth now redirects the browser back to the dashboard's own loopback port (auto-completing the login), and the manual paste path accepts Zed's user_id/access_token callback URL instead of erroring with "No authorization code found" ([#10517](https://github.com/diegosouzapw/OmniRoute/pull/10517)) - thanks @phatchau036 \ No newline at end of file diff --git a/changelog.d/fixes/10519-token-backed-web-session-test-dispatch.md b/changelog.d/fixes/10519-token-backed-web-session-test-dispatch.md new file mode 100644 index 0000000000..009f0bd2e5 --- /dev/null +++ b/changelog.d/fixes/10519-token-backed-web-session-test-dispatch.md @@ -0,0 +1 @@ +- **fix(providers):** test token-backed web sessions through their provider validator instead of the OAuth path ([#10519](https://github.com/diegosouzapw/OmniRoute/pull/10519)) — thanks @Zartharas diff --git a/changelog.d/fixes/10521-audit-extra-api-keys-redaction.md b/changelog.d/fixes/10521-audit-extra-api-keys-redaction.md new file mode 100644 index 0000000000..41222522de --- /dev/null +++ b/changelog.d/fixes/10521-audit-extra-api-keys-redaction.md @@ -0,0 +1 @@ +- **fix(compliance):** redact additional provider API keys from audit-log payloads ([#10521](https://github.com/diegosouzapw/OmniRoute/pull/10521)) — thanks @Zartharas diff --git a/changelog.d/fixes/10530-codex-combo-context.md b/changelog.d/fixes/10530-codex-combo-context.md new file mode 100644 index 0000000000..29ada2699a --- /dev/null +++ b/changelog.d/fixes/10530-codex-combo-context.md @@ -0,0 +1 @@ +- **fix(models):** align Codex GPT-5.6 context limits with the Codex catalog and honor model context overrides when advertising combos ([#10530](https://github.com/diegosouzapw/OmniRoute/issues/10530)) diff --git a/changelog.d/fixes/10540-deepseek-v4-efforts.md b/changelog.d/fixes/10540-deepseek-v4-efforts.md new file mode 100644 index 0000000000..339758ebcf --- /dev/null +++ b/changelog.d/fixes/10540-deepseek-v4-efforts.md @@ -0,0 +1 @@ +- **fix(deepseek):** Advertise `none`, `low`, `high`, and `max` for V4 Pro and Flash, derive OpenCode Go effort aliases from base-model metadata, and route those models through native Responses ([#10540](https://github.com/diegosouzapw/OmniRoute/pull/10540)) — thanks @jackjinke diff --git a/changelog.d/fixes/10544-a2a-tasks-timing-safe.md b/changelog.d/fixes/10544-a2a-tasks-timing-safe.md new file mode 100644 index 0000000000..f68ac49e3d --- /dev/null +++ b/changelog.d/fixes/10544-a2a-tasks-timing-safe.md @@ -0,0 +1 @@ +- **fix(a2a):** use a constant-time bearer compare in `/api/a2a/tasks` via `crypto.timingSafeEqual`, matching the `tokensMatch` helper already used in `src/app/a2a/route.ts` and removing the last non-constant secret comparison in the repo ([#10544](https://github.com/diegosouzapw/OmniRoute/pull/10544)) diff --git a/changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md b/changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md new file mode 100644 index 0000000000..bdfe33165a --- /dev/null +++ b/changelog.d/fixes/10571-opencode-session-stability-free-tier-routing.md @@ -0,0 +1 @@ +- **fix(providers):** OpenCode `x-opencode-session` now derives a stable, conversation-scoped fingerprint via `generateSessionId()` instead of a fresh random UUID per request, so upstream prompt caching can hit across requests in the same conversation; bare `big-pickle`/`*-free` model ids now keep routing to an active opencode-family connection even when its synced catalog is temporarily stale; and bare requests to no-auth catalog providers (e.g. `opencode`) now echo the listing-valid `/` form in `response.model` so clients validating against `/v1/models` don't warn ([#10571](https://github.com/diegosouzapw/OmniRoute/pull/10571)) diff --git a/changelog.d/fixes/10575-mcp-github-tool-search.md b/changelog.d/fixes/10575-mcp-github-tool-search.md new file mode 100644 index 0000000000..108466845f --- /dev/null +++ b/changelog.d/fixes/10575-mcp-github-tool-search.md @@ -0,0 +1 @@ +- **fix(mcp):** make GitHub skill tools discoverable through `omniroute_tool_search` diff --git a/changelog.d/fixes/10583-stt-nested-model-credential-fallback.md b/changelog.d/fixes/10583-stt-nested-model-credential-fallback.md new file mode 100644 index 0000000000..601915df18 --- /dev/null +++ b/changelog.d/fixes/10583-stt-nested-model-credential-fallback.md @@ -0,0 +1 @@ +- **fix(audio):** when a prefix-matched STT provider has no credentials, retry gateways that list the same nested model id (e.g. `deepgram/nova-3` → `openrouter/deepgram/nova-3`) and mention those ids in the 400; stop documenting bare `deepgram/nova-3` as the default example ([#10583](https://github.com/diegosouzapw/OmniRoute/issues/10583)) diff --git a/changelog.d/fixes/10601-xai-800-message-limit.md b/changelog.d/fixes/10601-xai-800-message-limit.md new file mode 100644 index 0000000000..3dcd33ab8e --- /dev/null +++ b/changelog.d/fixes/10601-xai-800-message-limit.md @@ -0,0 +1 @@ +- **fix(xai):** trim Chat Completions `messages` and Responses `input` to xAI's 800-item history cap before dispatch, so long tool loops no longer die on `413 Chat history exceeds the 800-message limit` ([#10601](https://github.com/diegosouzapw/OmniRoute/pull/10601)) diff --git a/changelog.d/fixes/10612-cli-token-machine-id-interop.md b/changelog.d/fixes/10612-cli-token-machine-id-interop.md new file mode 100644 index 0000000000..48ec5b8e1d --- /dev/null +++ b/changelog.d/fixes/10612-cli-token-machine-id-interop.md @@ -0,0 +1 @@ +- **fix(cli):** derive the machine-id token correctly under plain Node — `await import("node-machine-id")` puts the CJS exports on `.default`, so the destructured `machineIdSync` was `undefined` and the catch blanked the token, sending every management request unauthenticated; `OMNIROUTE_CLI_SALT` rotation is now honored too ([#10612](https://github.com/diegosouzapw/OmniRoute/pull/10612)) diff --git a/changelog.d/fixes/10613-setup-provider-api-key-collision.md b/changelog.d/fixes/10613-setup-provider-api-key-collision.md new file mode 100644 index 0000000000..0b8c3095f0 --- /dev/null +++ b/changelog.d/fixes/10613-setup-provider-api-key-collision.md @@ -0,0 +1 @@ +- **fix(cli):** `omniroute setup --add-provider --api-key ` no longer aborts with "Provider API key is required" — Commander bound the value to the program-level `--api-key` (the OmniRoute server key), leaving the subcommand's own option undefined; `OMNIROUTE_API_KEY` now works as the error message advertised ([#10613](https://github.com/diegosouzapw/OmniRoute/pull/10613)) diff --git a/changelog.d/fixes/auto-empty-pool-log-once.md b/changelog.d/fixes/auto-empty-pool-log-once.md new file mode 100644 index 0000000000..90d92ed82f --- /dev/null +++ b/changelog.d/fixes/auto-empty-pool-log-once.md @@ -0,0 +1 @@ +- **fix(auto):** rate-limit `auto/ matched no connected models` warnings to once per minute per label (`open-sse/services/autoCombo/virtualFactory.ts`) diff --git a/changelog.d/fixes/catalog-openrouter-gemini-embedding-2.md b/changelog.d/fixes/catalog-openrouter-gemini-embedding-2.md new file mode 100644 index 0000000000..21fd9808e3 --- /dev/null +++ b/changelog.d/fixes/catalog-openrouter-gemini-embedding-2.md @@ -0,0 +1 @@ +- **fix(providers):** register live OpenRouter Gemini Embedding 2 ids (`google/gemini-embedding-2` and `google/gemini-embedding-2-preview`, 3072-d) in the curated embeddings catalog so `GET /v1/models` and `GET /v1/embeddings` list the ids that already serve — thanks @RaviTharuma diff --git a/changelog.d/fixes/embed-gemini-missing-creds-hint.md b/changelog.d/fixes/embed-gemini-missing-creds-hint.md new file mode 100644 index 0000000000..41b61713f7 --- /dev/null +++ b/changelog.d/fixes/embed-gemini-missing-creds-hint.md @@ -0,0 +1 @@ +- **fix(api):** `/v1/embeddings` 400s for native `gemini-embedding-2` now name the working OpenRouter ids (`openrouter/google/gemini-embedding-2` and the preview alias) instead of only `No credentials for embedding provider: gemini` — thanks @RaviTharuma diff --git a/changelog.d/maintenance/embeddings-client-runbook.md b/changelog.d/maintenance/embeddings-client-runbook.md new file mode 100644 index 0000000000..da47b8d261 --- /dev/null +++ b/changelog.d/maintenance/embeddings-client-runbook.md @@ -0,0 +1 @@ +- **docs:** add an embeddings client runbook with live-verified working/broken model ids and Hindsight 0.9.1 / Memorix 1.6.0 notes — thanks @RaviTharuma diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 25a55d63d0..66b9ef93ec 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -1491,11 +1491,6 @@ "count": 1 } }, - "tests/integration/mimocode-proxy.integration.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 13 - } - }, "tests/integration/obsidian-plugin-e2e.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 12 @@ -2559,11 +2554,6 @@ "count": 2 } }, - "tests/unit/mimocode-executor.test.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 58 - } - }, "tests/unit/minimax-tts-1043.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 6 diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index b8cbd65265..05323c8002 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_08_18_10517_zed_hosted_oauth_callback_port": "PR #10517 (phatchau036, fix/zed-hosted-oauth-callback-port) own growth: src/shared/components/OAuthModal.tsx 1131->1148 (wc -l; check-file-size.mjs counts via split(\"\\n\").length so the gate sees 1134->1149, +15/+18, crosses the frozen 1134 cap). Wires the zed-hosted native-app callback auto-complete: forceManual gating on isTrueLocalhost for zed-hosted, the loopback-redirect-URI comment block, and the exchangeToken full-URL-as-code branch, all at the existing provider-switch chokepoints this modal already carries growth for (seventh bump: 969->989->993->998->1030->1056->1100->1149; structural shrink tracked in #3501). The actual port-derivation logic lives in src/lib/oauth/providers/zed-hosted.ts (not frozen here) and was hardened during pre-merge review to use the server's own getRuntimePorts() instead of a browser-guessed scheme/port, covered by the new tests/unit/zed-hosted-loopback-port-derivation.test.ts (8/8 passing).", "_rebaseline_2026_08_13_10243_codex_fingerprint_merge": "PR #10243 (xz-dev, Codex OAuth fingerprint convergence) merge into release/v3.8.50: src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts crossed the 1000-line new-file cap for the first time (974 on base, 997 on the PR's own branch, 1013 after merging + prettier reflow) purely from combining two independent, already-legitimate feature additions that landed on the same shared UI-helper file — this PR's own Codex fingerprint-mode select/toggle wiring (CODEX_FINGERPRINT_MODE_VALUES, getCodexFingerprintModeLabel, CodexFingerprintModeValue) plus #8949's unrelated Codex account-service-tier helpers merged concurrently on release/v3.8.50. Neither addition alone crosses the cap; git's line-level auto-merge does not detect a threshold crossing. Not modularized as part of this conflict-resolution merge commit (out of scope — this is a merge, not a feature change). Covered by the PR's own tests/unit/codex-fingerprint-convergence.test.ts, tests/unit/executor-codex.test.ts, tests/unit/provider-specific-data-schema.test.ts (all passing post-merge).", "_rebaseline_2026_08_09_8984_api_key_cache_mode": "PR #8984 own growth during the 2026-08-09 rebase: src/lib/db/apiKeys.ts 1529->1545 (+16 = the per-key apiKeys.cacheDefaultMode column + its row parsers and cascade wiring; additive at the existing connection write/read chokepoints). Covered by tests/unit/chatcore-semantic-cache.test.ts. (chatCore.ts stays at the pre-existing base-red ceiling — upstream tip already exceeds the frozen 5042, this PR only adds +2 on top; not re-bumped per the no-inherit-ratchet rule.)", "_rebaseline_2026_08_09_9207_breaker_halfopen_recovery": "PR #9207 own growth during the 2026-08-09 rebase: open-sse/services/accountFallback.ts 1978->2020 (+42 = recordProviderSuccess now also transitions the provider circuit breaker from HALF_OPEN to CLOSED when a request succeeds, so the breaker is not stuck half-open after repeated failures; the transition and its reset wiring grow the existing provider-success path, not extractable). Covered by tests/unit/provider-breaker-halfopen-recovery.test.ts.", @@ -378,7 +379,6 @@ "open-sse/mcp-server/tools/advancedTools.ts": 1456, "open-sse/services/accountFallback.ts": 2571, "open-sse/services/adobeFireflyBrowserLogin.ts": 1771, - "open-sse/services/adobeFireflyChromeRuntime.ts": 1561, "open-sse/services/adobeFireflyClient.ts": 3899, "open-sse/services/adobeFireflySession.ts": 1304, "open-sse/services/claudeCodeCompatible.ts": 1563, @@ -446,7 +446,8 @@ "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1387, "_rebaseline_2026_08_11_v3850_merge_storm_provider_registry": "DRIFT do merge-storm 2026-08-11 (99 PRs mergeados no release/v3.8.50). AddApiKeyModal.tsx (PR #8949 ChatGPT Web provider) e useProviderConnections.ts/ModelSelectModal.tsx (PRs #9011 combo test-all, #9499 image combos) = UI nova legitima acima do cap; gateways.ts = god-file de catalogo de providers que cresceu com PRs #9009/#9421/#9468/#9594 (qualquer split arriscaria corromper o merge de novo — o proprio PR #9421 quebrou o arquivo); bridge.ts (PR #8949) = ponte Chromium vendored; proxyFetch.ts 1207->1220 = drift herdado de merges. Owner autorizou rebaseline com anotacao (2026-08-11).", "src/lib/modelCapabilities.ts": 1006, - "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014 + "src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts": 1014, + "open-sse/config/imageRegistry.ts": 1019 }, "_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.", "_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).", @@ -582,7 +583,7 @@ "src/lib/memory/retrieval.ts": "1073", "src/lib/tailscaleTunnel.ts": "1202", "src/lib/usage/providerLimits.ts": "1013", - "src/shared/components/OAuthModal.tsx": "1134", + "src/shared/components/OAuthModal.tsx": "1146", "src/shared/components/RequestLoggerV2.tsx": "1629", "src/shared/components/analytics/charts.tsx": "1035", "src/shared/services/cliRuntime.ts": "1122", @@ -610,5 +611,6 @@ "_rebaseline_2026_08_12_v3850_basereds_round3": "Base-reds round 3 (#9985, 2026-08-12): ModelSelectModal.tsx 1135->1138 = base drift from the #10198 SWR/build repair (flagged as non-blocking drift by Release-Green run 31634993212, rebaselined here so the PR queue's Fast Quality Gates stop failing on inherited drift); gateways.ts 1215->1250 = base drift from the 08-12 merges (#10131 regolo/naga-ac repair, #9210 void-ai+helixmind) plus this PR restoring the chatanywhere metadata entry that round 2 dropped along with its duplicate (wave3 audited entry, +16 lines; same god-file no-split rationale as the 2026-08-11 annotation). Owner-authorized sweep (/sweep-reds).", "_rebaseline_2026_08_12_proxyfetch_redaction": "Base-reds round 3 (#9985): proxyFetch.ts 1220->1239 (+19) = redactProxyDetailsInMessage() helper closing the credential leak #10032 reintroduced (raw proxy URL with user:password appended to the propagated error, Hard Rule #12); irreducible security fix at the existing error-surface chokepoint. Covered by tests/unit/tls-proxy-context.test.ts (strengthened leak guards).", "_rebaseline_2026_08_12_modelcapabilities_snapshot_routing": "Base-reds round 3 (#9985): modelCapabilities.ts crossed the new-file cap at 1006 (+~10) when the context/max-input-token override lookups were routed through the #9199 bulk snapshot (fixing 323 per-model SQLite reads per catalog prepare — auto-combo-context-advertising guard); cohesive change at the existing resolution chokepoints, not extractable. Covered by tests/unit/auto-combo-context-advertising.test.ts + model-capability-resolution-snapshot-9199.test.ts.", - "_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule)." + "_rebaseline_2026_08_14_imagetotext_servicekinds": "Image-to-Text category (#10275/#10291): gateways.ts grew 1250→1255 by data lines only — the serviceKinds: [\"llm\", \"imageToText\"] declarations on the openrouter and chutes catalog entries, plus the 3-line comment recording why chutes needs no static dots.ocr entry (passthroughModels discovery). No new logic or branching; the file is a provider catalog of declarative metadata. Splitting a catalog for five lines would be worse than the growth (semantic-families rule).", + "_rebaseline_2026_08_18_imageregistry_merge_train": "merge-train 2026-08-18 (owner-authorized, /merge-prs batch of 84): open-sse/config/imageRegistry.ts crossed the 1000-line new-file cap for the first time purely from combining three independent, already-legitimate provider registrations boarded in the same local merge-train — #10542 (aihorde optional-key image catalog), #10494 (gemini-web image generation), #10594 (freepik/magnific provider rename + validation). 996 on release tip -> 1019 on the train tip. Each PR individually adds a small, additive IMAGE_PROVIDERS registry entry at the existing chokepoint; none crosses the cap alone. Not modularized as part of this train's gate fix (out of scope for a merge reconciliation, not a feature change). Covered by each PR's own focused tests (aihorde-image-catalog/generation, gemini-web image tests, freepik/magnific provider tests)." } diff --git a/config/quality/open-sse-typecheck-baseline.json b/config/quality/open-sse-typecheck-baseline.json index dc91ce1890..9b900ce2bd 100644 --- a/config/quality/open-sse-typecheck-baseline.json +++ b/config/quality/open-sse-typecheck-baseline.json @@ -1,176 +1,33 @@ { - "open-sse/executors/azure-openai.ts": { - "TS2345": 1 - }, - "open-sse/executors/chatgpt-web.ts": { - "TS2339": 1 - }, - "open-sse/executors/claude-web/stream.ts": { - "TS2322": 1, - "TS2345": 1 - }, - "open-sse/executors/copilot-web.ts": { - "TS2353": 1 - }, - "open-sse/executors/deepseek-web.ts": { - "TS2352": 1 - }, - "open-sse/executors/default.ts": { - "TS2352": 1 - }, - "open-sse/executors/duckduckgo-web.ts": { - "TS2345": 2 - }, - "open-sse/executors/duckduckgo-web/challenge.ts": { - "TS2304": 1 - }, - "open-sse/executors/edgeTts.ts": { - "TS2345": 1 - }, - "open-sse/executors/gemini-business.ts": { - "TS2339": 1 - }, - "open-sse/executors/ghe-copilot.ts": { - "TS2554": 1 - }, - "open-sse/executors/inner-ai.ts": { - "TS2352": 2 - }, - "open-sse/executors/theoldllm.ts": { - "TS2322": 1 - }, - "open-sse/executors/veoaifree-web.ts": { - "TS2322": 1 - }, - "open-sse/executors/windsurf.ts": { - "TS2322": 1 - }, - "open-sse/handlers/chatCore.ts": { - "TS2339": 30, - "TS2322": 1, - "TS2345": 11 - }, - "open-sse/handlers/chatCore/claudeUpstreamMessages.ts": { - "TS2345": 1 - }, "open-sse/handlers/chatCore/clientUsageBuffer.ts": { - "TS2345": 1 - }, - "open-sse/handlers/chatCore/clineResponseEnvelope.ts": { - "TS2698": 1 - }, - "open-sse/handlers/chatCore/compressionAnalyticsWrite.ts": { - "TS2724": 1 - }, - "open-sse/handlers/chatCore/nonStreamingResponseHeaders.ts": { - "TS2322": 2 - }, - "open-sse/handlers/chatCore/sanitization.ts": { - "TS2339": 1, - "TS2537": 1 - }, - "open-sse/handlers/chatCore/semanticCacheStore.ts": { - "TS2345": 1 - }, - "open-sse/handlers/chatCore/streamingPipeline.ts": { "TS2345": 2 }, - "open-sse/handlers/chatCore/streamingSemanticCacheStore.ts": { - "TS2345": 1 - }, - "open-sse/handlers/chatCore/thinkingSignatureRecovery.ts": { - "TS2339": 2 - }, - "open-sse/handlers/imageGeneration.ts": { - "TS2554": 2 - }, - "open-sse/handlers/responsesHandler.ts": { - "TS2339": 1, - "TS2345": 1 - }, - "open-sse/handlers/sseParser.ts": { - "TS2322": 2 - }, - "open-sse/handlers/videoGeneration.ts": { - "TS2339": 2 - }, - "open-sse/mcp-server/tools/compressionTools.ts": { - "TS2339": 2 - }, - "open-sse/services/__tests__/specificityDetector.test.ts": { - "TS2353": 2 - }, "open-sse/services/browserBackedChat.ts": { - "TS2322": 1, - "TS2794": 1 + "TS2353": 2 }, - "open-sse/services/claudeAdaptiveThinking.ts": { - "TS2352": 2 - }, - "open-sse/services/comboManifestMetrics.ts": { + "open-sse/services/compression/engines/omniglyphAdapter.ts": { "TS2307": 1 }, - "open-sse/services/compression/engines/ccr/index.ts": { + "open-sse/services/compression/stats.ts": { + "TS2307": 1 + }, + "open-sse/utils/cursorImages.ts": { "TS2339": 1 }, - "open-sse/services/payloadRules.ts": { - "TS2677": 1 - }, - "open-sse/services/tokenLimitCounter.ts": { - "TS2551": 1 - }, - "open-sse/transformer/responsesTransformer.ts": { + "open-sse/utils/imageNormalize.ts": { "TS2339": 1 }, "open-sse/utils/stream.ts": { - "TS2339": 7, - "TS2345": 1, - "TS2556": 1 + "TS2345": 2, + "TS2322": 2 }, - "src/app/api/v1/_shared/mediaGenerationRoute.ts": { - "TS2339": 2 + "open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/markdown.ts": { + "TS2307": 2 }, - "src/app/api/v1/models/catalog.ts": { - "TS2345": 1 - }, - "src/app/api/v1/models/catalogVision.ts": { - "TS2322": 1 - }, - "src/app/api/v1/videos/generations/route.ts": { + "src/lib/guardrails/videoBridgeHelpers.ts": { + "TS2488": 1, + "TS2365": 2, "TS2322": 1, "TS2345": 1 - }, - "src/lib/guardrails/visionBridge.ts": { - "TS2345": 1 - }, - "src/lib/providers/codexFastTier.ts": { - "TS2367": 1 - }, - "src/lib/skills/builtins.ts": { - "TS2322": 1 - }, - "src/lib/skills/injection.ts": { - "TS2339": 1 - }, - "src/lib/skills/webFetchExecution.ts": { - "TS2322": 1 - }, - "src/lib/streamingPiiTransform.ts": { - "TS2345": 1 - }, - "src/shared/providers/webSessionCredentials.ts": { - "TS2353": 1, - "TS2322": 1 - }, - "src/shared/validation/helpers.ts": { - "TS2339": 1 - }, - "src/sse/handlers/chat.ts": { - "TS2352": 1, - "TS2322": 2, - "TS2339": 1 - }, - "src/sse/services/model.ts": { - "TS2339": 4 } } diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index a33109e099..c8cbd07d94 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -179,7 +179,7 @@ "_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner." }, "vulnCount": { - "value": 10, + "value": 22, "direction": "down", "dedicatedGate": true }, @@ -396,5 +396,6 @@ "_zizmor_rebaseline_2026_06_19_a11y_148_reconcile": "RECONCILIACAO CROSS-PR (release-volatil) ao mergear #4321 (a11y) APOS #4322 (R1): zizmorFindings 145 -> 148. O #4322 ja rebaselinou 139->145 (drift base 142 + 3 unpinned-uses do mutation-redundancy.yml). Este PR adiciona +3 unpinned-uses @vN do novo job 'a11y' (nightly-resilience.yml): actions/checkout@v7, actions/setup-node@v6, actions/cache@v5.0.5 — MESMA convencao @vN deliberada e INTOCADA de todos os workflows (ver _scanner_harden_workflows_2026_06_16). Total = 142 base + 3 r1 + 3 a11y = 148, MEDIDO com `node scripts/check/check-workflows.mjs --ratchet` na arvore release(com #4322)+#4321 = 148 exato. Nenhum template-injection/artipacked/cache-poisoning novo.", "_zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse": "zizmorFindings 148 -> 152. Drift legitimo deste PR ao reutilizar o artefato next-build do job Build em package-artifact/electron-package-smoke e ao separar o build de compatibilidade Node 26: +4 unpinned-uses novos (2x actions/download-artifact@v8, actions/checkout@v7, actions/setup-node@v6). Mantida a convencao deliberada @vN dos workflows (sem SHA-pinning/manual update burden), conforme precedentes _scanner_harden_workflows_2026_06_16 e _zizmor_rebaseline_2026_06_19_*. Sem novos findings de template-injection/artipacked/cache-poisoning; medido localmente com zizmor 1.25.2 via `npm run check:workflows -- --ratchet` = 152.", "_cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct": "cognitiveComplexity 971->1223 (+252, +26.0% over pristine 971). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). v1 was +48 on 2026-07-27; v2 = v1 +20% buffer = +58 → +252 total (cycle 971 measured pristine → 1223 ceiling). Justification: same as complexity v2 — the v3.8.50 release cut coincides with high-merge activity; owner accepted enlarging the headroom to cover the entire PREPARE phase (5 minor cycles .50-.54) without per-PR rebaseline noise, given that re-tightening is mechanical at v3.8.51 via the combo.ts/chatCore.ts decomposition work scheduled in .51/.52 (ROADMAP.md). RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (shrink of 214 from structural extraction during the decomposition campaigns, or via npm run quality:ratchet -- --update if natural shrink appears earlier). The 1009 floor still gives 38 units of post-tighten headroom vs the current pristine 971. Tracked via same roadmap issue as complexity v2. Window: v3.8.50 (release cut) → v3.8.54 close (RE-TIGHTEN at v3.8.51 prep merge per ROADMAP.md). Last entry unless measured regression. v1 entry retained below for audit trail.", - "_cognitive_rebaseline_2026_07_27_3850_relax": "cognitiveComplexity 971->1019 (+48). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). +48 covers Train 1D (+15) + headroom for 3.8.50/.51 batches. RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (from combo.ts/chatCore.ts decomposition scheduled in .51/.52 per ROADMAP.md phases). Tracked via same roadmap issue as complexity. SUPERSEDED by _cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct (v1 +20% buffer) — retained for audit. Last entry unless measured regression." + "_cognitive_rebaseline_2026_07_27_3850_relax": "cognitiveComplexity 971->1019 (+48). OWNER-APPROVED TEMPORARY relax for v3.8.50-3.8.54 PREPARE phase (docs/ROADMAP.md). +48 covers Train 1D (+15) + headroom for 3.8.50/.51 batches. RE-TIGHTENING MANDATORY in v3.8.51: target 1009 (from combo.ts/chatCore.ts decomposition scheduled in .51/.52 per ROADMAP.md phases). Tracked via same roadmap issue as complexity. SUPERSEDED by _cognitive_rebaseline_2026_07_27_3850_relax_v2_20pct (v1 +20% buffer) — retained for audit. Last entry unless measured regression.", + "_vuln_rebaseline_2026_08_04_9439_cve_drift": "vulnCount 10->22 (HIGH=10, MODERATE=12, measured by osv-scanner v2.3.8 in PR #9439's own CI run). This is CVE variance, not a dependency change made by this PR: `git diff upstream/release/v3.8.50 HEAD -- package.json package-lock.json` is empty — neither file was touched anywhere in this branch's history. The osv-scanner vulnerability ratchet apparently does not run on every commit landed directly to release/v3.8.50 (same 'fast-gate PR->release skips this check' pattern already documented for check:file-size, e.g. _rebaseline_2026_07_01_v3843_release_5609), so newly-disclosed CVEs in already-present transitive dependencies accumulated on the release branch and only surfaced here because this PR's rebase onto the current release/v3.8.50 tip pulled them in. This exact scenario — 'a newly-disclosed CVE in an already-present dep can trip the gate with no dependency change on your part' — is the documented expected behavior in _osv_flip_blocking_2026_06_16_v3827 above, whose prescribed remedy is 'bump the dep, or re-baseline vulnCount with justification+issue' (docs/security/SUPPLY_CHAIN.md -> 'Variância de CVE'). osv-scanner is not available in this sandbox to enumerate the exact GHSA/CVE ids and safely bump only the affected transitive deps without a broader, separately-scoped dependency-audit pass; re-baselining here unblocks this PR without masking anything introduced by it. Tracked for follow-up: a dedicated dependency-bump PR should re-tighten vulnCount back down once the specific advisories are enumerated locally with osv-scanner installed." } diff --git a/docs/OMNIROUTE_ALLOCATION_HANDOFF.md b/docs/OMNIROUTE_ALLOCATION_HANDOFF.md new file mode 100644 index 0000000000..e523086c7c --- /dev/null +++ b/docs/OMNIROUTE_ALLOCATION_HANDOFF.md @@ -0,0 +1,9 @@ +# OmniRoute Allocation Handoff + +Allocation is not provider quota. + +Quota pools define which API keys may consume a provider pool and how hard, soft, or burst policies apply. Provider quota is external capacity reported by a provider or an explicitly configured source. Ghostlight internal budgets are governance limits defined by the administrator. + +The `ensurePool` operation is idempotent: an identical pool is unchanged, a changed allocation is updated, and a missing pool is created. This is intended for automation and bounded API callers. + +The read-only status endpoint is `GET /api/omniroute/status`. The verification command is `npm run omniroute:verify`; it makes no live model request. diff --git a/docs/OMNIROUTE_PROVIDER_FAILOVER.md b/docs/OMNIROUTE_PROVIDER_FAILOVER.md new file mode 100644 index 0000000000..4c0514b448 --- /dev/null +++ b/docs/OMNIROUTE_PROVIDER_FAILOVER.md @@ -0,0 +1,9 @@ +# OmniRoute Provider Failover + +Failures are classified before retry decisions are made. + +Transient failures such as timeouts, network errors, rate limits, and provider 5xx responses may fail over. Authentication errors, permission errors, invalid requests, unavailable models, and unknown failures are not retried blindly. + +The default cross-provider policy allows up to three provider attempts, retries rate limits and timeouts, and keeps administrative disablement separate from temporary circuit state. + +Circuit states are `closed`, `open`, and `half_open`. A cooldown schedules a bounded probe; a successful probe closes the circuit and a failed probe reopens it. diff --git a/docs/OMNIROUTE_QUOTA_TELEMETRY.md b/docs/OMNIROUTE_QUOTA_TELEMETRY.md new file mode 100644 index 0000000000..5fe939034f --- /dev/null +++ b/docs/OMNIROUTE_QUOTA_TELEMETRY.md @@ -0,0 +1,17 @@ +# OmniRoute Quota Telemetry + +OmniRoute separates provider quota telemetry from Ghostlight accounting. + +## Truthful states + +- `healthy` means a source reported usable remaining capacity. +- `approaching_limit` means a source reported remaining capacity at or below the configured threshold. +- `exhausted` is emitted only when a source reports zero capacity or usage at its limit. +- `unavailable` means a supported source failed to return data. +- `unknown` means no supported source exists or no provider limit is known. + +Unknown is not exhausted and does not disable a provider. + +Sources are preferred in this order: official provider API, authenticated usage API, explicitly mapped response headers, administrator configuration, local estimates, unknown. Local estimates are never presented as provider billing data. + +Response headers are parsed only through an explicit provider mapping. Generic header names are not assumed globally. diff --git a/docs/OMNIROUTE_ROUTING_POLICY.md b/docs/OMNIROUTE_ROUTING_POLICY.md new file mode 100644 index 0000000000..ee7ec45d87 --- /dev/null +++ b/docs/OMNIROUTE_ROUTING_POLICY.md @@ -0,0 +1,11 @@ +# OmniRoute Routing Policy + +Routing preserves the existing capability and combo selection logic, then applies allocation, health, circuit, quota, latency, reliability, model preference, and cost preference factors. + +The adaptive score is explainable and returns both the selected candidate and all ranked candidates. Exhausted quota, denied allocation, and open circuits are ineligible. Unknown quota remains eligible with a neutral quota factor. + +Route preview is deterministic and performs zero upstream model requests: + +`POST /api/omniroute/route/preview` + +The response includes candidate scores, factors, reasons, the selected provider, and `liveRequestExecuted: false`. diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md index da4095e2fc..d92aebb512 100644 --- a/docs/architecture/RESILIENCE_GUIDE.md +++ b/docs/architecture/RESILIENCE_GUIDE.md @@ -107,6 +107,40 @@ Before #7274, `resolveSessionAffinityTtlMs()` hard-bailed to `0` for every provi The three session-affinity headers are never forwarded upstream — executors build their own upstream headers from scratch rather than passing client headers through, so this stays an internal correlation id only. +### Exclusive managed session connection leases + +**Scope:** one active managed HTTP client/session owns one eligible OmniRoute connection. + +**Purpose:** provide durable exclusive connection ownership for clients that need a hard routing +fence across requests. This differs from session affinity, which is a soft continuity preference: +an exclusive lease persists lifecycle state in SQLite, enforces global active-owner and +active-connection uniqueness, and rejects a stale generation before provider dispatch. + +The feature is opt-in per API key. A managed key must have the `lease:exclusive` scope and an +explicit non-empty `allowedConnections` list. Any HTTP client can use the lifecycle endpoint; no +client name, user-agent, provider, OAuth method, or model is required. The lease owns a connection, +not a model, so a model change retains the binding while the connection remains ordinarily +eligible. Normal model, quota, health, cooldown, and allowlist rules remain authoritative and may +transition the same generation to another free eligible connection. + +The lifecycle is `POST /api/v1/session-leases` with JSON actions `acquire`, `renew`, and `release`. +Managed inference requests present the opaque `X-OmniRoute-Lease-Owner` value and exact +`X-OmniRoute-Lease-Generation`. The owner uses `vlo_` followed by 43 base64url characters; only +its SHA-256 hash is stored. Every final dispatch fence also binds the authenticated API key ID and +active connection ID. Lease control headers are removed from logs, retained request snapshots, and +upstream executor headers. + +If ordinary routing has eligible managed candidates but every free candidate is occupied by a +foreign active lease, OmniRoute returns HTTP `429`, lease-capacity-unavailable code, a +waiting-for-capacity state, and a bounded `Retry-After` derived from the earliest relevant expiry. +Ordinary empty eligibility is not lease contention and keeps its existing routing error semantics. + +Related mechanisms remain separate: + +- OAuth session occupancy is process-local soft distribution for OAuth accounts. +- Account semaphores grant request-concurrency permits and end when a request completes. +- Exclusive managed session leases are durable lifecycle ownership with a generation fence. + --- ## 3. Model Lockout diff --git a/docs/architecture/admission-lanes.md b/docs/architecture/admission-lanes.md index 8941a12eff..5a7aa5b0f3 100644 --- a/docs/architecture/admission-lanes.md +++ b/docs/architecture/admission-lanes.md @@ -1,7 +1,7 @@ --- title: "Admission lanes — two lane systems, what gates each, where each reports" status: active -lastUpdated: 2026-08-09 +lastUpdated: 2026-08-10 --- # Admission lanes (#9654) — two lane systems, what gates each, where each reports @@ -34,14 +34,57 @@ complementary; operators should know which one they are looking at. - **Tuning:** `OMNIROUTE_CHAT_VIRTUAL_LANES` + adaptive config (`maxQueueCount`, `maxQueueCost`, `defaultMaxWaitMs`, …). - **Reports:** `GET /api/monitoring/health` → `adaptiveAdmission` → `laneCount`, - `laneQueuedCount`, `laneQueuedCost`, `laneTenants` (opaque lane IDs, never raw keys). + `laneQueuedCount`, `laneQueuedCost`, `laneTenants` (opaque lane IDs, never raw + keys), and `virtualLanes` — the authoritative "lanes are on" flag in the snapshot. + +## 3. Fan-out probes — per-target admission for combo/fusion (#9654 Wave 2) + +Combo (priority / round-robin) and fusion fan out N model targets under one parent +request. Since #9654 Wave 2, **each fan-out target is gated before dispatch** by a +per-target probe (`PerTargetAdmissionHook`, built by `createPerTargetAdmissionHook`) +against the **parent's** tenant lane. + +- **Scope:** every fan-out target dispatched by combo, fusion, and the chaos engine. + System 1 (byte-level) is unaffected — it never probes fan-out targets. +- **Gate:** **opt-in with system 2.** A no-op when `OMNIROUTE_CHAT_VIRTUAL_LANES` + is unset — the parent request already holds the shared-queue lease in that mode, + so probing would double-count and reject combo targets. +- **Semantics:** + - **Strictly non-blocking — skip, never queue.** `maxWaitMs 0`: a full lane + skips the target and the combo's fallback machinery (or fusion's survivor + panel) serves instead. This is deliberate: a fan-out target is redundant + work, and queueing it piles more load onto the exact congestion lanes exist + to stop. `defaultMaxWaitMs` therefore applies to the **parent request only**; + fan-out probes never wait, and there is intentionally **no knob** to make + them wait (issue history shows wait knobs produced the mass-502/504 class + #9654 prevents — revisit only if an operator reports skipped fan-out targets + hurting response quality). + - **Release-on-admit.** An admitted probe releases its lease immediately: it is + a capacity gate, not a hold. The parent's lease covers the fan-out; holding N + more would inflate shared active cost and reject other tenants. Best-effort, + not a reservation: the lane can refill between probe and dispatch, so under + heavy contention the gate may admit into a lane that is full again by the + time the target dispatches. + - **Priced from the real fan-out body.** The probe estimates cost from the + target's actual body — including the request class derived from its `stream` + flag, exactly like the parent path — so fusion panel members (`stream: false`) + are priced at the non-streaming class they will truly occupy, and priority/RR + targets at whatever the user requested. +- **Reports:** a probe skip after the first target bumps combo's per-request + `fallbackCount` (mirroring the existing fallback semantics; visible in combo + logs); fusion returns 503 when every panel member is skipped. There is + **no aggregate counter** (e.g. `virtualFanoutSkipped`) on the snapshot today — + if an operator reports they cannot tell how often the lane gate skips fan-out + targets, that is the trigger to add one. ## Which one is showing in a dashboard - `adaptiveAdmission.laneCount` / `laneTenants` → **adaptive virtual lanes** (system 2). -- A health payload with **no** `adaptiveAdmission.lane*` fields usually means - `OMNIROUTE_CHAT_VIRTUAL_LANES` is unset — the byte-level lanes (system 1) are still - active, but nothing under `adaptiveAdmission` will report lane data until it is enabled. +- `adaptiveAdmission.virtualLanes === true` → the fan-out probes of section 3 are + also active. A payload with `virtualLanes` missing or `false` means + `OMNIROUTE_CHAT_VIRTUAL_LANES` is unset — the byte-level lanes (system 1) are + still active, but nothing under `adaptiveAdmission` (and no fan-out gating) is + in effect until it is enabled. ## Why both exist diff --git a/docs/compression/COMPRESSION_GUIDE.md b/docs/compression/COMPRESSION_GUIDE.md index 12b380379e..28443060e0 100644 --- a/docs/compression/COMPRESSION_GUIDE.md +++ b/docs/compression/COMPRESSION_GUIDE.md @@ -182,6 +182,22 @@ With Stacked: 10K-2.5K tokens sent (78-95% eligible RTK+Caveman range --- +## Output Styles + +Output styles inject a system prompt instruction to steer the model's writing style. They are defined in the output style catalog and support multiple languages and intensity levels (`lite`, `full`, `ultra`). + +| Style | Description | Supported Languages | Levels | +| --- | --- | --- | --- | +| `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. | `en`, `pt-BR`, `ja`, `id`, `vi` | `lite`, `full`, `ultra` | +| `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` | +| `ponytail` | Lazy senior-dev discipline: climb the YAGNI ladder, fix root cause, smallest working diff. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` | +| `i-have-adhd` | Action-first output: next action leads, steps numbered, one concrete next step, no preamble. | `en`, `pt-BR`, `vi`, `ja`, `id` | `lite`, `full`, `ultra` | +| `terse-cjk` | Classical-Chinese ultra-terse style (locale-gated to zh). | `zh` | `lite`, `full`, `ultra` | + +Each level appends a shared boundary clause ensuring that code blocks, URLs, file paths, commands, and identifiers remain verbatim. + +--- + ## Configuration ### Dashboard @@ -446,6 +462,60 @@ Caveman output mode is **opt-in** — set it via the combo config: } ``` +### Output Styles (catalog) + +Caveman output mode above is the **legacy single-style path**. Phase 4 generalized it +into a catalog of composable output styles: `OUTPUT_STYLE_CATALOG` in +`open-sse/services/compression/outputStyles/catalog.ts`. Each style is a system-prompt +instruction that makes the model itself produce cheaper output; styles can be enabled +together and are injected in catalog order. + +| Style | `id` | What it does | Instruction languages | +| --- | --- | --- | --- | +| Terse prose | `terse-prose` | Drop filler/articles/hedging; keep technical substance exact. Same text as the legacy caveman output mode (referenced, not re-typed). | en, pt-BR, ja, id | +| Less code | `less-code` | YAGNI ladder: smallest working change, no unrequested abstractions. | en only (backlog: [#10426](https://github.com/diegosouzapw/OmniRoute/issues/10426)) | +| Ponytail (lazy senior dev) | `ponytail` | "The best code is the code never written": reuse > rewrite, root cause > symptom, shortest working diff. | en, pt-BR, vi, ja, id | +| I have ADHD (action-first) | `i-have-adhd` | Action first (command/path/snippet before prose), numbered bounded steps, ONE concrete next step, no preamble/recap/closers. Adapted from [ayghri/i-have-adhd](https://github.com/ayghri/i-have-adhd) (MIT). | en, pt-BR, vi, ja, id | +| Terse CJK (文言) | `terse-cjk` | Classical-Chinese ultra-terse style. | zh (locale-gated: only offered when the detected language is `zh`) | + +Every style ships three intensity levels — `lite`, `full`, `ultra` — and every level +ends with the shared boundaries clause, which keeps code blocks, file paths, commands, +error strings, URLs and identifiers verbatim. + +#### How injection works + +`applyOutputStyles()` (`open-sse/services/compression/outputStyles/apply.ts`) resolves +the selection against the catalog (unknown ids and locale-mismatched styles are +dropped, never an error), concatenates the selected instructions in catalog order, +appends the boundaries clause **once**, and front-loads the result into the system +prompt behind a single idempotency marker (`[OmniRoute Output Styles]`) — re-applying +is a no-op. When the detected request language has a translation, the localized +instruction is injected instead of English. + +#### How to enable + +In the dashboard: **Context → Settings → Compression** — one row per style with an +on/off toggle and a level selector. Programmatically, the compression config persists +the selection as: + +```json +{ + "outputStyles": [ + { "id": "i-have-adhd", "level": "full" }, + { "id": "less-code", "level": "lite" } + ] +} +``` + +Back-compat: the legacy `outputMode: "caveman"` combo setting still works and maps to +`terse-prose`, byte-identical to the old injection in all four legacy languages. + +The style × language matrix is pinned by +`tests/unit/compression/output-styles-i18n-matrix.test.ts`: a new style cannot ship +without at least a pt-BR translation (or an explicit tracked exception), and an +existing style cannot silently lose a locale. To add a style, see +[EXTENDING_COMPRESSION.md](./EXTENDING_COMPRESSION.md#adding-an-output-style). + ### Tool Result Compression The `toolResultCompressor.ts` module provides **5 specialized compression strategies** diff --git a/docs/compression/EXTENDING_COMPRESSION.md b/docs/compression/EXTENDING_COMPRESSION.md index 7b33f5b37d..e4d8cb6609 100644 --- a/docs/compression/EXTENDING_COMPRESSION.md +++ b/docs/compression/EXTENDING_COMPRESSION.md @@ -568,6 +568,40 @@ gate (`check:compression-budget`). --- +## Adding an Output Style + +Output styles (see the [guide's catalog table](./COMPRESSION_GUIDE.md#output-styles-catalog)) +are the response-side counterpart of the input engines: instead of compressing what you +send, they instruct the model to produce cheaper output. The registry is +`OUTPUT_STYLE_CATALOG` in `open-sse/services/compression/outputStyles/catalog.ts`, and +**one catalog entry is the entire feature**: the injector, the dashboard settings panel, +persistence and telemetry all enumerate the catalog — there is no other list to update. + +1. **Add one entry to `OUTPUT_STYLE_CATALOG`** with `id`, `label`, `description` and the + three English `levels` (`lite`, `full`, `ultra`). Every level must end with + `${SHARED_BOUNDARIES}` so code, paths, commands, errors and URLs stay verbatim. + The instruction text must be **static and deterministic** per + `(id, level, language)` — `${SHARED_BOUNDARIES}` is the only interpolation allowed. +2. **Translate it.** Ship at least a `pt-BR` block under `i18n`; `ponytail` and + `i-have-adhd` (en, pt-BR, vi, ja, id) are the reference shape. A deliberately + single-language style sets `locale` instead (like `terse-cjk` → `zh`) and is then + only offered under that locale. +3. **Update the matrix guard** — add the style's languages to `BASELINE_LANGUAGES` in + `tests/unit/compression/output-styles-i18n-matrix.test.ts`. The gate fails any new + non-locale-gated style without the required translations unless it carries an + explicit `KNOWN_ENGLISH_ONLY` entry with a tracking issue. +4. **Add a per-style test** modeled on + `tests/unit/compression/i-have-adhd-catalog.test.ts`: catalog shape, boundaries + clause per level, and an anchor asserting each translation is written in its own + language rather than copied English. +5. **Attribution**: if the style is adapted from an upstream project, credit it in a + source comment on the entry (e.g. `i-have-adhd` → ayghri/i-have-adhd, MIT) — same + rule as "Proposing an upstream-inspired improvement" above. + +No UI, schema or telemetry change is needed — those surfaces render from the catalog. + +--- + ## Best Practices ### Engine Development diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg index 99bc29b327..7de5802465 100644 --- a/docs/diagrams/cli-terminal.svg +++ b/docs/diagrams/cli-terminal.svg @@ -1,4 +1,4 @@ - + Compact animated terminal cycling three real OmniRoute CLI commands with a typewriter effect and a scrolling subcommand ticker; the first frame shows the completed providers-list screen. diff --git a/docs/diagrams/comparison-table.svg b/docs/diagrams/comparison-table.svg index 80b3cbcdb2..053acf8e98 100644 --- a/docs/diagrams/comparison-table.svg +++ b/docs/diagrams/comparison-table.svg @@ -1,4 +1,4 @@ - + Static-header comparison table where each capability row fades in top to bottom; the OmniRoute column is highlighted and shows a check or a leading value in every row, while competitors show a mix of checks, partials and crosses. diff --git a/docs/diagrams/promise-pillars.svg b/docs/diagrams/promise-pillars.svg index a25437f78c..7330e4930b 100644 --- a/docs/diagrams/promise-pillars.svg +++ b/docs/diagrams/promise-pillars.svg @@ -1,4 +1,4 @@ - + Animated promise card: six pillar tiles fade in in reading order, then a soft colored border highlight sweeps from tile to tile in a continuous cycle. @@ -21,7 +21,7 @@ - One endpoint. 341 providers. Never stop building — OmniRoute picks the cheapest one that works. + One endpoint. 340 providers. Never stop building — OmniRoute picks the cheapest one that works. @@ -38,7 +38,7 @@ Never hit limits - Auto-fallback across 341 providers in + Auto-fallback across 340 providers in milliseconds. Quota out? The next provider takes over — zero downtime. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index 0182df44a2..037a32c9ae 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,4 +1,4 @@ - + Animated hero card: a pulse travels the divider line and a compression bar demo repeatedly shrinks a prompt by up to 95 percent; all headline content is static and readable on the first frame. @@ -28,7 +28,7 @@ Never stop coding. - Every AI tool → 341 providers90+ free — through one endpoint. + Every AI tool → 340 providers90+ free — through one endpoint. Claude Code · Codex · Cursor · Cline · Copilot · Antigravity  →  FREE Claude / GPT / Gemini · auto-fallback diff --git a/docs/guides/CLI-INTEGRATIONS.md b/docs/guides/CLI-INTEGRATIONS.md index 893476668e..7ea32fdb6c 100644 --- a/docs/guides/CLI-INTEGRATIONS.md +++ b/docs/guides/CLI-INTEGRATIONS.md @@ -18,12 +18,31 @@ There are also two launchers — `omniroute launch` (Claude Code) and `omniroute launch-codex` (Codex) — that spawn the CLI with the right env injected, without writing any config at all. +Provider onboarding is available from the same local/remote context. The +API-first commands below keep management authentication separate from provider +credentials and never print a credential in structured output: + +```bash +omniroute providers add glm --credential-env GLM_API_KEY --name work +omniroute providers import ./providers.json --dry-run --json +omniroute providers auth openai +omniroute providers edit --default-model glm/glm-5.2 +omniroute providers remove --yes +``` + +For scripts, prefer `--credential-stdin` or `--credential-env`; `--credential` +is retained for controlled local use. `providers remove` requires `--yes` on a +non-interactive terminal, and all five commands honor the active context or the +global `--base-url`/`--api-key` options. + For the one-time, hand-written base setup of the two richest integrations, see the per-tool deep dives: - [Claude Code configuration](./CLAUDE-CODE-CONFIGURATION.md) - [Codex CLI configuration](./CODEX-CLI-CONFIGURATION.md) - [Remote Mode](./REMOTE-MODE.md) — drive a remote OmniRoute (VPS / Tailnet) from your laptop +- [VS Code Copilot Chat](./VSCODE-COPILOT.md) — the OmniCopilot extension; it can also run these + `setup-*` commands for you from inside the editor --- @@ -35,23 +54,23 @@ Every command honours the **active context** (set with `omniroute connect`, see with `--remote` (or an active remote context) it fetches the catalog from that server and writes the config locally. -| Command | Tool | What it writes | Key flags | Local vs remote | -| -------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------- | -| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/.config.toml` — one profile per compatible text model (`codex --profile `) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both | -| `omniroute setup-claude` | Claude Code | `~/.claude/profiles//settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both | -| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json` — `omniroute` provider with every catalog model (`opencode -m omniroute/`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both | -| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both | -| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both | -| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml` — `provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both | -| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both | -| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both | -| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json` — `openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both | -| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both | -| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both | -| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both | -| `omniroute run ` | Runtime launch (generic) | Nothing — spawn `claude`/`codex` with the right env and args | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--dry-run` `--json` `--port` `--profile` `--token` | Both | -| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both | -| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both | +| Command | Tool | What it writes | Key flags | Local vs remote | +| -------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------- | +| `omniroute setup-codex` | OpenAI Codex CLI | `~/.codex/.config.toml` — one profile per compatible text model (`codex --profile `) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--codex-home` | Both | +| `omniroute setup-claude` | Claude Code | `~/.claude/profiles//settings.json` — one profile per matched model (`CLAUDE_CONFIG_DIR`) | `--remote` `--api-key` `--only` `--dry-run` `--port` `--claude-home` | Both | +| `omniroute setup-opencode` | OpenCode (openai-compatible) | `~/.config/opencode/opencode.json` — `omniroute` provider with every catalog model (`opencode -m omniroute/`) | `--remote` `--api-key` `--only` `--model` `--dry-run` `--port` | Both | +| `omniroute setup-cline` | Cline | `~/.cline/data/{globalState,secrets}.json` (CLI mode) + prints VS Code extension settings | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--cline-dir` | Both | +| `omniroute setup-kilo` | Kilo Code | `~/.local/share/kilo/auth.json` (CLI) + merges `kilocode.*` into VS Code `settings.json` if present | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--auth-path` `--vscode-settings` | Both | +| `omniroute setup-continue` | Continue / `cn` CLI | `~/.continue/config.yaml` — `provider: openai` models, key via `${{ secrets.OMNIROUTE_API_KEY }}` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both | +| `omniroute setup-cursor` | Cursor | Nothing — prints the in-app steps (Cursor config is opaque SQLite) | `--remote` `--api-key` `--only` `--port` | Both | +| `omniroute setup-roo` | Roo Code | `~/.omniroute/roo-settings.json` (import doc) + sets `roo-cline.autoImportSettingsPath` if a VS Code `settings.json` exists | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--import-path` `--vscode-settings` | Both | +| `omniroute setup-crush` | Crush | `~/.config/crush/crush.json` — `openai-compat` provider, key via `$OMNIROUTE_API_KEY` | `--remote` `--api-key` `--only` `--dry-run` `--port` `--config-path` | Both | +| `omniroute setup-goose` | Goose | `~/.config/goose/config.yaml` (`GOOSE_PROVIDER`/`OPENAI_HOST`/`GOOSE_MODEL`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both | +| `omniroute setup-aider` | Aider | `~/.aider.conf.yml` (`openai-api-base` + `model: openai/`) + prints env recipe | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` | Both | +| `omniroute setup-qwen` | Qwen Code | `~/.qwen/settings.json` — V4 `modelProviders.openai` array + `OMNIROUTE_API_KEY` in `~/.qwen/.env` | `--remote` `--api-key` `--model` `--yes` `--dry-run` `--port` `--config-path` `--env-path` | Both | +| `omniroute run ` | Runtime launch (generic) | Nothing — spawn `claude`/`codex`/`aider`/`goose`/`opencode`/`qwen`/`gemini` with the right env and args; Qwen and Gemini use a temporary isolated home | `--remote` `--base-url` `--context` `--provider` `--model` `--api-key` `--api-key-env` `--dry-run` `--json` `--port` `--profile` `--token` | Both | +| `omniroute launch` | Claude Code | Nothing — spawns `claude` with `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` injected | `--remote` `--api-key` `--token` `--profile` `--port` | Both | +| `omniroute launch-codex` | OpenAI Codex CLI | Nothing — spawns `codex` with the `omniroute` provider injected via `-c` flags | `--remote` `--api-key` `--profile` (`-p`) `--port` | Both | Notes on flags (verified in the command source): @@ -74,6 +93,20 @@ Notes on flags (verified in the command source): a profile written by `setup-claude` / `setup-codex`, plus pass-through args for the underlying `claude` / `codex` binary. +The interactive picker is also shared by the setup recipes: + +```bash +# Pick from the active local or remote model catalog and configure the target. +omniroute configure claude +omniroute configure opencode --provider glm +omniroute configure qwen --model qwen/qwen3.8-max-preview --yes +``` + +`configure` currently delegates to the tested recipes for `codex`, `claude`, +`opencode`, `qwen`, `aider`, `goose`, `cline`, `continue`, and `kilo`. IDE-only, +MITM, and guide-only catalog entries remain explicit `setup-*`/manual flows and +are not presented as launchable targets. + > `setup-opencode` is the **lightweight openai-compatible** OpenCode integration. > There is also a richer plugin integration — `omniroute setup opencode` — which > installs `@omniroute/opencode-plugin`. They are different commands; the table @@ -116,6 +149,11 @@ omniroute launch-codex # Codex CLI → local OmniRoute omniroute launch-codex --profile glm52 omniroute run claude --model openai/gpt-5.4 omniroute run codex --model openai/gpt-5.4 --dry-run --json +omniroute run aider --model glm/glm-5.2 -- --message "reply OK" +omniroute run goose --model glm/glm-5.2 +omniroute run opencode --model glm/glm-5.2 -- run "reply OK" +omniroute run qwen --model glm/glm-5.2 -- -p "reply OK" +omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "reply OK" # Explicit command path: pass through whatever comes after -- omniroute run claude -- --print-system-prompt "review this diff" @@ -171,6 +209,7 @@ tool expects (verified in the command source): | `setup-claude` (`ANTHROPIC_BASE_URL`), `launch` | root | No — Claude Code appends `/v1/messages` | | `setup-codex`, `launch-codex` (`model_providers.omniroute.base_url`) | with `/v1` | Yes | | `setup-qwen` (`modelProviders.openai[].baseUrl`) | with `/v1` | Yes | +| `run gemini` (`GOOGLE_GEMINI_BASE_URL`) | root | No — the SDK appends `/v1beta/models/…` | --- @@ -200,6 +239,56 @@ outdated), `--apply` (install without prompting), `--changelog`, `--no-backup`, --- +## Google Gemini CLI via `omniroute run gemini` + +Contract verified against `@google/gemini-cli` 0.50.0: the CLI honors +`GOOGLE_GEMINI_BASE_URL` and issues `POST /v1beta/models/:generateContent` +(and `:streamGenerateContent?alt=sse`) against it — exactly OmniRoute's native +Gemini surface (`/v1beta`). `omniroute run gemini` wires that automatically: + +- `GOOGLE_GEMINI_BASE_URL` → the active OmniRoute base URL (root, no `/v1`); +- `GEMINI_API_KEY` → the resolved OmniRoute credential (option/env/context); +- a **temporary isolated `GEMINI_CLI_HOME`** whose `.gemini/settings.json` + selects `gemini-api-key` auth, so a stored Google OAuth session (Code Assist) + never overrides the OmniRoute-directed launch — removed after exit; +- `--model ` injection from `--provider`/`--model`. + +```bash +omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "hello" +``` + +Gemini's workspace-trust guard still applies in headless mode — pass +`--skip-trust` (or trust the directory interactively) yourself; the launcher +deliberately does not bypass it. This launcher is distinct from the **ACP +registration** (`src/lib/acp/registry.ts`, `gemini --acp`), which remains the +agent-protocol integration for `/dashboard/acp-agents`. + +--- + +## Real smoke sweep (opt-in) + +Deterministic launch-plan regression runs in CI (`tests/unit/cli/run-command.test.ts`, +`tests/unit/cli/run-execution.test.ts`). To validate the REAL binaries against a REAL +OmniRoute server, an opt-in harness exists at +`tests/integration/upstream-cli-smoke.int.test.ts`. It never runs automatically +(every sub-test skips unless `RUN_CLI_SMOKE=1`), passes the credential by env-var +NAME (never by value), redacts key-shaped strings from any recorded output, skips +targets whose binary is not installed, and classifies failures as +auth / upstream / config instead of a bare boolean: + +```bash +RUN_CLI_SMOKE=1 \ +OMNIROUTE_SMOKE_BASE_URL="http://localhost:20128" \ +OMNIROUTE_SMOKE_MODEL="" \ +OMNIROUTE_SMOKE_API_KEY_ENV="OMNIROUTE_API_KEY" \ +node --import tsx/esm --test tests/integration/upstream-cli-smoke.int.test.ts +``` + +Optional: `OMNIROUTE_SMOKE_TARGETS="codex,opencode,qwen"` restricts the sweep; +`OMNIROUTE_SMOKE_TIMEOUT_MS` overrides the 120s per-target timeout. + +--- + ## See also - [Claude Code configuration](./CLAUDE-CODE-CONFIGURATION.md) — the deeper Claude Code guide diff --git a/docs/guides/CODEX-CLI-CONFIGURATION.md b/docs/guides/CODEX-CLI-CONFIGURATION.md index 0749637fe5..bde7cf0c9b 100644 --- a/docs/guides/CODEX-CLI-CONFIGURATION.md +++ b/docs/guides/CODEX-CLI-CONFIGURATION.md @@ -10,6 +10,15 @@ Complete guide for using the Codex CLI pointed at OmniRoute as an OpenAI-compati --- +> **TOML is the only effective format.** Modern Codex reads `~/.codex/config.toml` +> exclusively (verified against codex-cli 0.147.0: `codex --help` documents +> `-c/--config` overrides "loaded from `~/.codex/config.toml`"). The old +> `~/.codex/config.yaml` belonged to the legacy npm CLI and is silently ignored. +> The dashboard generator (`/api/cli-tools/apply`, tool `codex`) writes TOML with a +> conservative merge — existing keys and other provider blocks are preserved, the +> API key stays in `OMNIROUTE_API_KEY` (never in the file), and a leftover legacy +> `config.yaml` is reported as a migration note without being touched. + ## Ready-to-paste config.toml Replace `` and `` with your values: diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 6503ac343b..464aa55319 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -329,10 +329,13 @@ prefix). Traefik should route `PathPrefix(`/omniroute`)` to the container withou `StripPrefix`, so Next.js receives `/omniroute/...` and serves assets from `/omniroute/_next/...`. -The Docker healthcheck probes `/api/monitoring/health` prefixed with the active -`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. +The Docker healthcheck probes the lightweight `/healthz` lifecycle endpoint prefixed +with the active `OMNIROUTE_BASE_PATH`. `/api/monitoring/health` remains available for +human/dashboard diagnostics; to point the container HEALTHCHECK back at it (for example +for deep health enforcement), set `OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health`. +That path is a **deep** check (DB + monitoring summary) — appropriate for Docker's +infrequent `HEALTHCHECK` if you opt back in, but **not** for Kubernetes `livenessProbe` +intervals. For orchestrators (Kubernetes, Nomad, etc.): diff --git a/docs/guides/REMOTE-MODE.md b/docs/guides/REMOTE-MODE.md index 2e0e202227..b5fb1035ae 100644 --- a/docs/guides/REMOTE-MODE.md +++ b/docs/guides/REMOTE-MODE.md @@ -271,13 +271,41 @@ omniroute configure codex # non-interactive omniroute configure codex --provider glm --model glm/glm-5.2 --name glm52 + +# keep a frequently used model at the top of the interactive picker +omniroute configure codex --provider glm --model glm/glm-5.2 --favorite --yes ``` +The picker keeps only model IDs (never URLs or credentials) in the local +`model-preferences.json` file, scoped by context and CLI target. Favorites are +shown before recent selections; use `--unfavorite` to remove a selected model +from that context/target list. + The written profile references the inference key by env var (`OMNIROUTE_API_KEY`) — the secret is never written to disk. For the one-time base Codex setup (the `[model_providers.omniroute]` block), see [CODEX-CLI-CONFIGURATION.md](./CODEX-CLI-CONFIGURATION.md). +### Launching a CLI against the remote (no config written) + +`omniroute run ` also honours the active context: the remote base URL +and the context credential are injected into the spawned process only. + +```bash +omniroute connect 192.168.0.15 +omniroute run claude --model openai/gpt-5.4 # Claude Code → remote +omniroute run gemini --model glm/glm-5.2 -- --skip-trust -p "hello" +omniroute run opencode --model glm/glm-5.2 -- run "reply OK" + +# Preview exactly what would be spawned (env KEY NAMES only, never values): +omniroute run codex --dry-run --json +``` + +Targets: `claude`, `codex`, `aider`, `goose`, `opencode`, `qwen`, `gemini` +(single source: `bin/cli/cli-manifest.mjs`). Qwen and Gemini run with a +temporary isolated home that is removed on exit, so the launch never touches — +or leaks into — your personal tool configuration. + ### Per-CLI setup commands Each supported CLI has a remote-aware setup command (all honour the active @@ -360,14 +388,20 @@ omniroute contexts remove stg --yes > revoke the token on the server with `omniroute tokens revoke ` to actually > kill access. -**Export / import** contexts (e.g. to move them between machines — secrets included, -so handle the file carefully): +**Export / import** contexts (e.g. to move them between machines). New contexts persist +only a keychain reference; credentials are not copied into the export when the OS +keychain is available: ```bash omniroute contexts export --out contexts.json # default: stdout omniroute contexts import contexts.json # overwrite; --merge to keep existing +omniroute contexts migrate --yes # move legacy plaintext tokens to keychain ``` +On headless systems without a usable OS keychain, the CLI falls back to +`config.json` with mode `0600` and prints a one-time warning. Treat exports from +that fallback (and any legacy config before migration) as secret material. + --- ## Quick end-to-end check @@ -409,8 +443,12 @@ omniroute contexts remove 192-168-0-15 --yes # drop the local context (even if - `omniroute connect` reuses the login brute-force lockout + audit logging. - Prefer HTTPS or a Tailnet for the transport; a bare host defaults to `http://` for LAN/Tailscale convenience — pass a full `https://…` URL for TLS. -- The local context file is `~/.omniroute/config.json` (`chmod 600`); tokens are - never printed in logs (masked to a prefix). +- The preferred local context file is `~/.omniroute/config.json` (`chmod 600`) + containing only a `credentialRef`; the token itself is stored in the OS + keychain (`keytar`) and is never printed in logs. Headless installs without a + working native keychain use the same `0600` file as an explicit fallback and + emit a warning once. Use `omniroute contexts migrate --yes` after installing a + keychain backend. --- diff --git a/docs/guides/SETUP_GUIDE.md b/docs/guides/SETUP_GUIDE.md index fdd622c1ec..68bf2e8e90 100644 --- a/docs/guides/SETUP_GUIDE.md +++ b/docs/guides/SETUP_GUIDE.md @@ -56,6 +56,8 @@ npm install PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm run dev ``` +> **Windows note:** By default, OmniRoute uses `%APPDATA%\omniroute` when the legacy `%USERPROFILE%\.omniroute` directory is not present. Set `DATA_DIR` to choose a different data-directory location. + > **Note:** `npm install` auto-generates `.env` from `.env.example` on first run. Subsequent installs will not overwrite an existing `.env`, so customizations are preserved. To re-seed, delete `.env` before re-running. ### Docker diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index 61bfcadaf5..75c8e4b601 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -478,7 +478,7 @@ If a provider repeatedly enters OPEN state: ### "Unsupported model" error -- Ensure you're using the correct prefix: `deepgram/nova-3` or `assemblyai/best` +- Use a model id whose first segment is a provider you have credentials for (`openai/whisper-1`, `openrouter/deepgram/nova-3`). Bare `deepgram/nova-3` requires a native Deepgram key. - Verify the provider is connected in **Dashboard → Providers** ### Transcription returns empty or fails diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 654e26dd48..0d237635cc 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -948,9 +948,12 @@ Content-Type: multipart/form-data curl -X POST http://localhost:20128/v1/audio/transcriptions \ -H "Authorization: Bearer your-api-key" \ -F "file=@audio.mp3" \ - -F "model=deepgram/nova-3" + -F "model=openai/whisper-1" ``` +`deepgram/nova-3` is the native Deepgram route and needs a Deepgram API key. +If only OpenRouter is configured, use `openrouter/deepgram/nova-3`. + **Speech-to-Text (transcription)** providers: - `openai/` (whisper-compatible) diff --git a/docs/guides/VSCODE-COPILOT.md b/docs/guides/VSCODE-COPILOT.md new file mode 100644 index 0000000000..ccdbcadcb0 --- /dev/null +++ b/docs/guides/VSCODE-COPILOT.md @@ -0,0 +1,138 @@ +--- +title: "VS Code Copilot Chat — OmniCopilot extension" +version: 3.8.50 +lastUpdated: 2026-08-18 +--- + +# VS Code Copilot Chat — OmniCopilot extension + +**OmniCopilot** puts every model your OmniRoute serves into the *native* GitHub Copilot Chat +model picker. No second sidebar, no separate chat UI — Copilot's agent mode, tool calling, +MCP servers and custom instructions all keep working, just running on the model you pick. + +| | | +| --- | --- | +| **Install (VS Code)** | [Marketplace → `diegosouzapw.omnicopilot`](https://marketplace.visualstudio.com/items?itemName=diegosouzapw.omnicopilot) | +| **Install (forks)** | [Open VSX](https://open-vsx.org/extension/diegosouzapw/omnicopilot) — Cursor, Windsurf, VSCodium, Theia, code-server, Gitpod, Antigravity, Kiro | +| **Source / issues** | [github.com/diegosouzapw/OmniCopilot](https://github.com/diegosouzapw/OmniCopilot) (MIT) | +| **Requires** | VS Code 1.104+ | + +> **No Copilot subscription needed.** Since VS Code 1.122 a language-model provider works +> without a GitHub sign-in and without any Copilot plan. Inline completions and +> embeddings-based features stay outside the provider API and still require Copilot. + +--- + +## Setup + +1. **Run OmniRoute** — `npm install -g omniroute && omniroute` (dashboard on `http://localhost:20128`). +2. **Install the extension** — search "OmniRoute" in the Extensions view. +3. **Pick a model** — Copilot Chat → model picker → **Manage Models…** → **OmniRoute**, then tick + what you want. + +Nothing to configure when OmniRoute runs on the default port. For a remote instance, open the +**OmniRoute icon in the Activity Bar** (or run `OmniRoute: Manage Connection`) and set: + +- **Server URL** — the server root, e.g. `http://192.168.0.15:20128`. The `/v1` suffix is + appended by the extension; do not include it. +- **API key** — only when the server sets `REQUIRE_API_KEY`. Stored in the OS keychain via VS + Code SecretStorage, never in `settings.json`. + +--- + +## What the picker will show + +The extension does not show the raw `GET /v1/models` payload — it shapes it, and the count you +see is lower than the catalog size for two deliberate reasons. + +### It asks for one id per model + +`MODELS_CATALOG_PREFIX_MODE` defaults to **`dual`**, which advertises every model twice — once +under the short alias prefix and once under the canonical provider prefix — so older client +configs keep resolving either form: + +``` +cc/claude-sonnet-4-6 ← alias prefix +claude/claude-sonnet-4-6 ← canonical prefix, same model +``` + +The extension requests **`GET /v1/models?prefix=alias`** so one id arrives per model, without +changing the server-wide setting for your other clients. On a reference instance this collapsed +**2345 entries to 1396 — 949 duplicates, zero models lost.** + +If you would rather fix it server-wide for *every* client, set the +`MODELS_CATALOG_PREFIX_MODE` feature flag to `alias` in the dashboard. See +[API_REFERENCE → prefix](../reference/API_REFERENCE.md#model-id-prefixes-prefix) for the +query parameter and the warning about `canonical`. + +### It hides models that cannot chat + +The catalog also lists image, video, audio, rerank, embedding and moderation models. Those are +rejected on a chat request anyway: + +``` +HTTP 400 — Model '' is an image-generation model and cannot be used on +/v1/chat/completions. Use POST /v1/images/generations instead. +``` + +so they are filtered out by their `type` field before reaching the picker. **Responses-API +models are kept** — every Codex / GPT-5.x entry advertises `supported_endpoints: ["responses"]`, +and OmniRoute translates those for `/v1/chat/completions`, so they are perfectly usable. + +### Providers you never configured + +The catalog lists models from providers with an **active connection** *plus* every **noAuth** +provider — the keyless ones that make up much of the free tier. That is intentional. To hide +them, add them to `blockedProviders` in the dashboard settings; nothing changes in the +extension. + +--- + +## Dashboard inside a VS Code tab + +`omnicopilot.dashboardOpen: "editor"` renders the OmniRoute dashboard in an editor tab via the +Simple Browser instead of an external browser. Embedding is **opt-in on the server**: start +OmniRoute with + +```bash +DASHBOARD_ALLOW_EMBED=vscode omniroute +``` + +which serves the HTML pages with `frame-ancestors 'self' vscode-webview:` instead of the default +`frame-ancestors 'none'` + `X-Frame-Options: DENY`. The API surface (`/api`, `/v1`, `/v1beta`, +`/a2a`, `/healthz`) keeps the strict headers either way. Without the variable the page refuses to +frame and the extension falls back to the external browser — nothing breaks. See +[`ENVIRONMENT.md`](../reference/ENVIRONMENT.md) and issue +[#10273](https://github.com/diegosouzapw/OmniRoute/issues/10273). + +--- + +## Configuring your other tools from inside VS Code + +**`OmniRoute: Configure Coding CLI`** drives the `omniroute` CLI to write ready-to-use profiles +for Codex CLI, Claude Code, Cline, Continue, Cursor, Aider, OpenCode, Goose, Crush, Qwen Code, +Kilo and Roo — the same configs described in +[`CLI-INTEGRATIONS.md`](CLI-INTEGRATIONS.md). The API key is handed to the CLI through the +`OMNIROUTE_API_KEY` environment variable, never on the command line. + +--- + +## Troubleshooting + +| Symptom | Cause / fix | +| --- | --- | +| No OmniRoute models in the picker | Server unreachable. The status-bar dot goes grey; run `OmniRoute: Check Connection`. Discovery is silent by design and contributes no models rather than prompting. | +| Every model appears twice | You are on an OmniCopilot older than 1.0.1 — update. The extension now requests `?prefix=alias`. | +| An image/audio model used to be listed and is gone | Intentional since 1.0.1 — it could never answer a chat request. | +| Panel missing from the Activity Bar | VS Code moves extra view containers into the **"…"** overflow at the bottom of the Activity Bar, and a container hidden via right-click stays hidden. Right-click the Activity Bar → tick **OmniRoute**, or open it with `OmniRoute: Manage Connection`. | +| Dashboard opens in the browser despite `editor` mode | The server is not started with `DASHBOARD_ALLOW_EMBED=vscode` (see above). The fallback is deliberate. | +| Models list is stale after changing providers | `OmniRoute: Refresh Models`, or the ↻ link in the panel. | + +--- + +## See also + +- [`CLI-INTEGRATIONS.md`](CLI-INTEGRATIONS.md) — every other coding tool +- [`REMOTE-MODE.md`](REMOTE-MODE.md) — driving a remote OmniRoute +- [`../reference/API_REFERENCE.md`](../reference/API_REFERENCE.md) — the `/v1/models` contract +- [`docs/CATALOG.md`](https://github.com/diegosouzapw/OmniCopilot/blob/main/docs/CATALOG.md) — the extension's own catalog notes diff --git a/docs/guides/meta.json b/docs/guides/meta.json index 7f9506b4cf..d7a3a94fdb 100644 --- a/docs/guides/meta.json +++ b/docs/guides/meta.json @@ -16,6 +16,7 @@ "CLAUDE-CODE-CONFIGURATION", "CODEX-CLI-CONFIGURATION", "CLI-INTEGRATIONS", + "VSCODE-COPILOT", "MANAGEMENT-AUTH", "REMOTE-MODE", "PWA_GUIDE", diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index 0e9595dd23..9485cc2edc 100644 --- a/docs/i18n/ar/llm.txt +++ b/docs/i18n/ar/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 7e7013db28..9d879fbe27 100644 --- a/docs/i18n/az/llm.txt +++ b/docs/i18n/az/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 7e7013db28..9d879fbe27 100644 --- a/docs/i18n/bg/llm.txt +++ b/docs/i18n/bg/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index 3247e4efaa..57385efc73 100644 --- a/docs/i18n/bn/llm.txt +++ b/docs/i18n/bn/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index aa26c9d307..1518e70342 100644 --- a/docs/i18n/cs/llm.txt +++ b/docs/i18n/cs/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index 92026bec0a..7d1f1ee0b7 100644 --- a/docs/i18n/da/llm.txt +++ b/docs/i18n/da/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 2bb8879645..db556b0cbe 100644 --- a/docs/i18n/de/llm.txt +++ b/docs/i18n/de/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index 8393a6352c..23fea2da1c 100644 --- a/docs/i18n/es/llm.txt +++ b/docs/i18n/es/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 6112eb7470..96427aa846 100644 --- a/docs/i18n/fa/llm.txt +++ b/docs/i18n/fa/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index 4e47eaae21..796379e087 100644 --- a/docs/i18n/fi/llm.txt +++ b/docs/i18n/fi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index 106ed41699..c0408640aa 100644 --- a/docs/i18n/fr/llm.txt +++ b/docs/i18n/fr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index c25ec09af1..c33a384936 100644 --- a/docs/i18n/gu/llm.txt +++ b/docs/i18n/gu/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index c819680d65..f49dd8c721 100644 --- a/docs/i18n/he/llm.txt +++ b/docs/i18n/he/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index 380e7ec2c1..e95d8688cc 100644 --- a/docs/i18n/hi/llm.txt +++ b/docs/i18n/hi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index 1eea21dd51..81b55a6aaa 100644 --- a/docs/i18n/hu/llm.txt +++ b/docs/i18n/hu/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index dafa9c0a83..c03b43615f 100644 --- a/docs/i18n/id/llm.txt +++ b/docs/i18n/id/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/in/llm.txt b/docs/i18n/in/llm.txt index e7a8175d67..7d2a245d18 100644 --- a/docs/i18n/in/llm.txt +++ b/docs/i18n/in/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index d19aad5335..e448146ad6 100644 --- a/docs/i18n/it/llm.txt +++ b/docs/i18n/it/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index 4581d48a8b..cc29fdb105 100644 --- a/docs/i18n/ja/llm.txt +++ b/docs/i18n/ja/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index c8f7c33585..84f63922bc 100644 --- a/docs/i18n/ko/llm.txt +++ b/docs/i18n/ko/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index b2c7bcb398..c29dc81d45 100644 --- a/docs/i18n/mr/llm.txt +++ b/docs/i18n/mr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index 40afc36364..3ba8d25a9b 100644 --- a/docs/i18n/ms/llm.txt +++ b/docs/i18n/ms/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index c36b126f73..fb1e502db7 100644 --- a/docs/i18n/nl/llm.txt +++ b/docs/i18n/nl/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index 089b364abb..8f79c3e3ff 100644 --- a/docs/i18n/no/llm.txt +++ b/docs/i18n/no/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index c8c3fd60ee..2dec4c0693 100644 --- a/docs/i18n/phi/llm.txt +++ b/docs/i18n/phi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/pl/docs/guides/DOCKER_GUIDE.md b/docs/i18n/pl/docs/guides/DOCKER_GUIDE.md index 662348fed5..50515eb837 100644 --- a/docs/i18n/pl/docs/guides/DOCKER_GUIDE.md +++ b/docs/i18n/pl/docs/guides/DOCKER_GUIDE.md @@ -224,8 +224,10 @@ prefiksu). Traefik powinien routować `PathPrefix(`/omniroute`)` do kontenera be `StripPrefix`, żeby Next.js otrzymywał `/omniroute/...` i serwował assety z `/omniroute/_next/...`. -Healthcheck Dockera sonduje `/api/monitoring/health` z prefiksem aktywnego -`OMNIROUTE_BASE_PATH`. +Healthcheck Dockera sonduje lekki endpoint cyklu życia `/healthz` z prefiksem aktywnego +`OMNIROUTE_BASE_PATH`. `/api/monitoring/health` pozostaje dostępny do diagnostyki +człowieka/pulpit; aby ustawić HEALTHCHECK kontenera z powrotem na niego (np. dla +głębokiej kontroli stanu), ustaw `OMNIROUTE_HEALTHCHECK_PATH=/api/monitoring/health`. ## Docker Compose z Caddy (HTTPS Auto-TLS) diff --git a/docs/i18n/pl/docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md b/docs/i18n/pl/docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md index a08c63839f..5a633a0656 100644 --- a/docs/i18n/pl/docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md +++ b/docs/i18n/pl/docs/providers/ALIBABA-QWEN-PROVIDER-FAMILIES.md @@ -32,7 +32,7 @@ innych rodzin endpointów, więc wszystkie cztery produkty pozostają osobnymi I | Rodzina providera | `global-sg` | `china-beijing` | Format wire | | ----------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------- | ----------- | | `alibaba` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | -| `bailian-coding-plan` | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1` | `https://coding.dashscope.aliyuncs.com/apps/anthropic/v1` | Anthropic | +| `bailian-coding-plan` | `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1` | `https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic/v1` | Anthropic | | `qwen-cloud` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | | `qwen-cloud-token-plan` | `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1` | OpenAI | diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 656e1742fc..16eaa93a6f 100644 --- a/docs/i18n/pl/llm.txt +++ b/docs/i18n/pl/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index 5c9a3d8a7a..bf476bb8e3 100644 --- a/docs/i18n/pt-BR/llm.txt +++ b/docs/i18n/pt-BR/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 4c6dba527a..3dc57c2f34 100644 --- a/docs/i18n/pt/llm.txt +++ b/docs/i18n/pt/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index a5f5c4d00a..2339427fc4 100644 --- a/docs/i18n/ro/llm.txt +++ b/docs/i18n/ro/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ru/README.md b/docs/i18n/ru/README.md index d21aaf4f56..54664d68d3 100644 --- a/docs/i18n/ru/README.md +++ b/docs/i18n/ru/README.md @@ -298,7 +298,7 @@ Combo: "always-on" strategy: priority + также · Aider · Goose · Hermes · Kiro · Antigravity · Windsurf · AMP · любой OpenAI-compatible tool
-📖 Setup 33 tools → [`docs/reference/CLI-TOOLS.md`](../../reference/CLI-TOOLS.md) · OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider) +📖 Setup 34 tools → [`docs/reference/CLI-TOOLS.md`](../../reference/CLI-TOOLS.md) · OpenCode plugin → [`@omniroute/opencode-provider`](https://www.npmjs.com/package/@omniroute/opencode-provider)
diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index de3deff289..c256e9bb8d 100644 --- a/docs/i18n/ru/llm.txt +++ b/docs/i18n/ru/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index 8cba00bbac..6b4fa58433 100644 --- a/docs/i18n/sk/llm.txt +++ b/docs/i18n/sk/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index fdfa98b483..b57a3c7691 100644 --- a/docs/i18n/sv/llm.txt +++ b/docs/i18n/sv/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 4c44968c4d..d517a36e59 100644 --- a/docs/i18n/sw/llm.txt +++ b/docs/i18n/sw/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index e293ffa1bb..f53d254c53 100644 --- a/docs/i18n/ta/llm.txt +++ b/docs/i18n/ta/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 84645b5600..5391f5a324 100644 --- a/docs/i18n/te/llm.txt +++ b/docs/i18n/te/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 0193696d1d..144b6e0bdb 100644 --- a/docs/i18n/th/llm.txt +++ b/docs/i18n/th/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 4401f086d6..10e6e61d38 100644 --- a/docs/i18n/tr/llm.txt +++ b/docs/i18n/tr/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index bb28ccb28d..dd4895b928 100644 --- a/docs/i18n/uk-UA/llm.txt +++ b/docs/i18n/uk-UA/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index c95e62602d..fa6bc3fb7a 100644 --- a/docs/i18n/ur/llm.txt +++ b/docs/i18n/ur/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index 130c17af64..fe52242de5 100644 --- a/docs/i18n/vi/llm.txt +++ b/docs/i18n/vi/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index 4b7272323d..6dd819a0e6 100644 --- a/docs/i18n/zh-CN/llm.txt +++ b/docs/i18n/zh-CN/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index 55c36bb840..2aef156267 100644 --- a/docs/i18n/zh-TW/llm.txt +++ b/docs/i18n/zh-TW/llm.txt @@ -4,7 +4,7 @@ --- -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -18,7 +18,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -169,7 +169,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -438,7 +438,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -479,7 +479,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 55ff6a4f8f..79cee7893b 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -56,6 +56,8 @@ tags: background scheduler tick. - name: API Keys description: API key management + - name: Session Leases + description: Client-neutral exclusive managed session connection leases - name: Combos description: Routing combo management - name: Settings @@ -103,6 +105,76 @@ tags: See docs/frameworks/TRAFFIC_INSPECTOR.md. paths: + /api/v1/session-leases: + post: + tags: + - Session Leases + summary: Acquire, renew, or release an exclusive managed connection lease + description: | + Requires an API key with `lease:exclusive` and an explicit non-empty + `allowedConnections` policy. The opaque owner is bound to the authenticated API key; + the lease owns an eligible connection, not a provider or model. Managed inference + requests present the owner and exact generation headers. Temporary foreign occupancy + returns 429 `WAITING_FOR_CAPACITY` with `Retry-After`. + security: + - BearerAuth: [] + parameters: + - name: X-OmniRoute-Lease-Owner + in: header + required: true + schema: + type: string + pattern: ^vlo_[A-Za-z0-9_-]{43}$ + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: object + required: [action, model] + properties: + action: { type: string, const: acquire } + model: { type: string, minLength: 1, maxLength: 512 } + - type: object + required: [action, generation] + properties: + action: { type: string, const: renew } + generation: { type: integer, minimum: 1 } + - type: object + required: [action, generation] + properties: + action: { type: string, const: release } + generation: { type: integer, minimum: 1 } + reason: + type: string + enum: [OWNER_EXIT, CLIENT_CANCELLED] + responses: + "200": + description: Lease lifecycle state without connection or credential disclosure + content: + application/json: + schema: + $ref: "#/components/schemas/ExclusiveConnectionLeaseLifecycle" + "400": + description: Missing or invalid lease context/action + "401": + description: Missing or invalid API key + "403": + description: Managed lease scope or key configuration required + "409": + description: Stale generation, missing binding, or connection fence rejection + "415": + description: Lifecycle mutations require application/json + "429": + description: Eligible managed connections are held by foreign active leases + headers: + Retry-After: + schema: { type: integer, minimum: 1, maximum: 3600 } + content: + application/json: + schema: + $ref: "#/components/schemas/ExclusiveConnectionLeaseCapacity" # --- Playground + Search Tools (plans 17+18) --- /api/playground/improve-prompt: post: @@ -1371,6 +1443,38 @@ paths: cost is computed per modality when pricing is available, otherwise `0` (fail-open). + /api/v1/multimodal-embeddings: + post: + tags: [Embeddings] + summary: Create embeddings (Jina multimodal-embeddings alias) + description: >- + Same handler as `POST /api/v1/embeddings`. Provided so Jina-compatible + clients that call `/v1/multimodal-embeddings` do not receive HTTP 404 + `unknown_route`. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [input, model] + additionalProperties: true + responses: + "200": + description: Embedding vectors (same contract as POST /api/v1/embeddings). + "401": + $ref: "#/components/responses/Unauthorized" + get: + tags: [Embeddings] + summary: List embedding models (Jina multimodal-embeddings alias) + security: + - BearerAuth: [] + responses: + "200": + description: Embedding model catalog (same as GET /api/v1/embeddings). + /api/v1/providers/{provider}/embeddings: post: tags: [Embeddings] @@ -5498,7 +5602,7 @@ paths: x-loopback-only: true tags: [System] summary: Extract bounded Video Bridge frames through the internal broker - description: Internal per-process-authenticated trusted-loopback broker. Accepts at most 50 MiB of video bytes; URLs, paths, executable names, and command arguments are not part of the contract. The body pipeline and streamed handler reader both enforce the input cap. The broker applies fixed FFmpeg/ffprobe confinement, a single extraction slot with four pending jobs/100 MiB queued input, a 4 MiB per-frame cap, and a 32 MiB total response cap. This is not a public upload API. + description: Internal per-process-authenticated trusted-loopback broker. Accepts at most 50 MiB of video bytes; URLs, paths, executable names, and command arguments are not part of the contract. The body pipeline and streamed handler reader both enforce the input cap. The broker applies fixed FFmpeg/ffprobe confinement, a single extraction slot with four pending jobs/100 MiB queued input, a 4 MiB per-frame cap, and a 32 MiB total response cap. Optional focus bounds and scene-aware sampling are deterministic and bounded. Transcript provenance is a metadata contract on the parent video part, not an instruction to run speech-to-text. This is not a public upload API. security: [] parameters: - in: query @@ -5508,6 +5612,28 @@ paths: type: integer minimum: 1 maximum: 16 + - in: query + name: samplingPolicy + required: false + description: Optional deterministic sampling policy. Scene-aware detection falls back to uniform sampling on detector failure. + schema: + type: string + enum: [uniform, scene_aware, segment_aware] + default: uniform + - in: query + name: start + required: false + description: Optional focus-window start in seconds. The broker clamps it to the media duration. + schema: + type: number + minimum: 0 + - in: query + name: end + required: false + description: Optional focus-window end in seconds. It must be greater than the normalized start. + schema: + type: number + minimum: 0 requestBody: required: true content: @@ -5540,6 +5666,83 @@ paths: "504": description: Fixed 120-second broker extraction deadline exceeded + /api/modality-bridge/video/drilldown: + get: + x-loopback-only: true + tags: [System] + summary: Read a bounded Video Bridge drill-down slice + description: Internal loopback/token-authenticated lookup into a short-lived per-session frame cache. It never downloads media or starts a subprocess; start/end and frame count only select already materialized frames. + security: [] + parameters: + - in: query + name: sessionId + required: true + schema: { type: string, maxLength: 128 } + - in: query + name: videoRef + required: true + schema: { type: string, maxLength: 4096 } + - in: query + name: start + required: false + schema: { type: number, minimum: 0 } + - in: query + name: end + required: false + schema: { type: number, minimum: 0 } + - in: query + name: frames + required: false + schema: { type: integer, minimum: 1, maximum: 16 } + responses: + "200": { description: Bounded cached frame slice } + "403": { description: Trusted loopback/token identity required } + "404": { description: Drill-down session or media key was not found } + post: + x-loopback-only: true + tags: [System] + summary: Store a bounded Video Bridge drill-down result + description: Internal lifecycle operation for explicitly authorized callers. The short-lived session cache is isolated by session and media reference and does not alter the primary request cost. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sessionId, videoRef, durationSeconds, frames] + properties: + sessionId: { type: string, maxLength: 128 } + videoRef: { type: string, maxLength: 4096 } + durationSeconds: { type: number, exclusiveMinimum: 0, maximum: 600 } + frames: + type: array + minItems: 1 + maxItems: 16 + items: + type: object + required: [timestampSeconds, dataUri] + properties: + timestampSeconds: { type: number, minimum: 0 } + dataUri: { type: string, pattern: "^data:image/jpeg;base64," } + responses: + "201": { description: Drill-down result stored } + "403": { description: Trusted loopback/token identity required } + "413": { description: Payload exceeds the bounded session budget } + delete: + x-loopback-only: true + tags: [System] + summary: Delete a Video Bridge drill-down session + security: [] + parameters: + - in: query + name: sessionId + required: true + schema: { type: string, maxLength: 128 } + responses: + "200": { description: Session entries removed } + "403": { description: Trusted loopback/token identity required } + /api/cache/stats: get: tags: [System] @@ -7215,6 +7418,31 @@ components: requestId: 0a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d schemas: + ExclusiveConnectionLeaseLifecycle: + type: object + required: [state, generation, acquiredAt, renewedAt, expiresAt] + properties: + state: { type: string, enum: [ACTIVE, RELEASED] } + generation: { type: integer, minimum: 1 } + acquiredAt: { type: string, format: date-time } + renewedAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time } + ExclusiveConnectionLeaseCapacity: + type: object + required: [state, error, reason, retryAfter, eligibleCount, freeCount] + properties: + state: { type: string, const: WAITING_FOR_CAPACITY } + error: + type: object + required: [type, code, message] + properties: + type: { type: string, const: lease_error } + code: { type: string, const: LEASE_CAPACITY_UNAVAILABLE } + message: { type: string } + reason: { type: string, const: NO_FREE_ELIGIBLE_CONNECTION } + retryAfter: { type: integer, minimum: 1, maximum: 3600 } + eligibleCount: { type: integer, minimum: 0 } + freeCount: { type: integer, minimum: 0 } EmbeddingMultimodalItem: oneOf: - type: object @@ -8498,7 +8726,12 @@ components: type: string url: type: string - description: Redacted subscription URL. + description: >- + Redacted subscription URL. May be a local/loopback address + (e.g. `http://127.0.0.1:8080/list`) — local-first fetch targets + are allowed by default (`OMNIROUTE_ALLOW_LOCAL_PROVIDER_URLS`); + cloud-metadata / link-local endpoints (169.254.0.0/16) are always + blocked. enabled: type: boolean mode: diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md index 2d843f8ba9..93d900510f 100644 --- a/docs/reference/API_REFERENCE.md +++ b/docs/reference/API_REFERENCE.md @@ -15,6 +15,7 @@ Complete reference for all OmniRoute API endpoints. ## Table of Contents - [Chat Completions](#chat-completions) +- [Exclusive Managed Session Leases](#exclusive-managed-session-leases) - [Embeddings](#embeddings) - [Image Generation](#image-generation) - [Document OCR](#document-ocr) @@ -87,6 +88,64 @@ Content-Type: application/json > **Cache-hit cost semantics:** on a semantic-cache HIT (`X-OmniRoute-Cache-Hit: true`) no upstream call is made, so `X-OmniRoute-Response-Cost` is `0.0000000000` (the **incremental** cost of serving the hit). The original/would-have-been cost is reported separately in `X-OmniRoute-Cost-Saved`. Billing consumers should sum `X-OmniRoute-Response-Cost` (hits cost nothing); cache analytics can aggregate `X-OmniRoute-Cost-Saved`. +## Exclusive Managed Session Leases + +Exclusive managed session leasing is an opt-in, client-neutral routing contract: one active owner +holds one eligible OmniRoute connection. It does not lease a model, require OAuth, identify a +particular client, or require a particular provider. + +The authenticating API key must have scope `lease:exclusive` and an explicit non-empty +`allowedConnections` list. The database mutation boundary enforces both fields together on key +creation and partial updates. + +```http +POST /api/v1/session-leases +Authorization: Bearer +Content-Type: application/json +X-OmniRoute-Lease-Owner: vlo_<43-base64url-characters> + +{"action":"acquire","model":"glm/glm-4.6"} +``` + +Successful lifecycle responses expose timestamps, `state`, and the exact positive `generation`, +but never the selected connection or credentials. Renew and release supply the generation in the +JSON body: + +```json +{ "action": "renew", "generation": 1 } +``` + +```json +{ "action": "release", "generation": 1, "reason": "OWNER_EXIT" } +``` + +Every managed inference request then supplies both control headers: + +```http +X-OmniRoute-Lease-Owner: vlo_<43-base64url-characters> +X-OmniRoute-Lease-Generation: 1 +``` + +The exact owner, generation, active connection, and authenticated API key are fenced immediately +before each supported upstream attempt. Replaying owner and generation with another key fails even +when that key permits the same connection. Raw owners are not persisted, logged, retained in the +request snapshot, or forwarded upstream. + +Temporary contention returns HTTP `429` with `Retry-After` and: + +```json +{ + "state": "WAITING_FOR_CAPACITY", + "error": { "type": "lease_error", "code": "LEASE_CAPACITY_UNAVAILABLE" }, + "reason": "NO_FREE_ELIGIBLE_CONNECTION", + "retryAfter": 30 +} +``` + +This response only means that the ordinary eligible set was non-empty and every free candidate was +held by a foreign active lease. Unsupported models/providers, policy mismatch, cooldown, quota, +health, and other ordinary eligibility failures retain their existing OmniRoute responses. + ### `x-omniroute-compression` Per-request override of the compression plan. Highest precedence — beats the routing-combo @@ -129,18 +188,43 @@ Content-Type: application/json } ``` -Available providers: Nebius, OpenAI, Mistral, Together AI, Fireworks, NVIDIA, **OpenRouter**. +Available providers: Nebius, OpenAI, Mistral, Together AI, Fireworks, NVIDIA, **OpenRouter**, Jina AI. + +Catalog ids are `provider/model` (example: `jina-ai/jina-embeddings-v5-omni-small`). Bare Jina model ids that appear in the registry (for example `jina-embeddings-v5-text-small`, `jina-reranker-v3.5`) also resolve. Jina embed/rerank/classify/segment use dashboard `jina-ai` credentials first; `JINA_AI_API_KEY` is a fallback only when no dashboard key exists. The `jina-reader` card is Reader / `r.jina.ai` only (`POST /v1/web/fetch`) and never serves embeddings or rerank. Registry models that advertise multimodal support also accept up to 32 provider-neutral structured items. Media item types are `text`, `image`, `audio`, `video`, and `document`. Their media `source` is either `{"type":"url","url":"https://..."}` or `{"type":"base64","data":"...","media_type":"..."}`. +Jina v5 Omni (`jina-ai/jina-embeddings-v5-omni-small`, `jina-ai/jina-embeddings-v5-omni-nano`, +and the family alias `jina-ai/jina-embeddings-v5-omni` → omni-small) also accepts Jina's native +EmbeddingsV5Request docs and **forwards them intact** to `https://api.jina.ai/v1/embeddings`: + +```json +{ + "model": "jina-ai/jina-embeddings-v5-omni-small", + "task": "retrieval.query", + "normalized": true, + "input": [ + { "text": "a red bicycle" }, + { "image": "https://example.com/bike.png" }, + { "content": [{ "text": "caption" }, { "image": "data:image/png;base64,..." }] } + ] +} +``` + +Native `{ image | audio | video | pdf }` values may be a public HTTPS URL, a `data:` URI, or raw +base64. OmniRoute does not stringify those objects or fetch native image URLs — Jina retrieves +public media itself. Extra Jina fields (`task`, `normalized`, `truncate`, `embedding_type`) are +forwarded. Text-only Jina SKUs still reject non-text docs. + Security and transport bounds: -- Remote media URLs must be public HTTPS. OmniRoute fetches them server-side with redirect - revalidation, timeout, decoded size limits, public DNS checks, and connection pinning to a - validated answer before the provider call. Providers never receive the original remote URL. +- Remote media URLs must be public HTTPS. Canonical `{type,source:url}` items are fetched + server-side (redirect revalidation, timeout, size limits, public DNS, connection pinning) and + inlined before the provider call. Jina-native `{image:"https://..."}` items are forwarded as-is + after the same public-HTTPS check; Jina fetches the URL. - Inline base64 media is limited to 8 MiB decoded per item and 16 MiB decoded across the request. Provider translation (canonical items are never forwarded unchanged): @@ -270,6 +354,31 @@ Authorization: Bearer your-api-key → Returns all chat, embedding, and image models + combos in OpenAI format ``` +### Model id prefixes (`?prefix=`) + +Most models are advertised under a **provider prefix**. Which prefix you get is controlled by +the `MODELS_CATALOG_PREFIX_MODE` feature flag, and can be overridden **per request** with a +query parameter — useful for a client that wants a clean list without changing the server-wide +setting for everyone else: + +```bash +GET /v1/models?prefix=alias # one id per model — the short alias prefix +GET /v1/models?prefix=dual # both forms (server default) +GET /v1/models?prefix=canonical # only the full provider-id prefix +``` + +| Mode | Emits | Notes | +| ----------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dual` | `cc/claude-sonnet-4-6` **and** `claude/claude-sonnet-4-6` | **Default.** Both ids route to the same model; kept so client configs that hardcoded either form keep working. Roughly doubles the catalog. | +| `alias` | `cc/claude-sonnet-4-6` | One entry per model. Providers without a distinct alias still emit their entry, so nothing is lost. | +| `canonical` | `claude/claude-sonnet-4-6` | ⚠️ The canonical row is only emitted when the canonical provider id **differs** from the alias, so providers without a distinct alias emit nothing in this mode. Prefer `alias` for a de-duplicated list. | + +A `dual`-mode mirror can also be recognised without the query parameter: it carries a `parent` +field pointing at the primary id. + +Clients that render a model picker should request `?prefix=alias` — this is what the +[OmniCopilot VS Code extension](../guides/VSCODE-COPILOT.md) does. + ### No-thinking model variants For thinking-capable Claude models, `/v1/models` also advertises a **no-thinking** variant whose id is prefixed with `claude-3-omniroute-no-thinking/`: @@ -313,6 +422,8 @@ Use this endpoint when a sidecar runs out-of-process and cannot import | POST | `/v1/audio/transcriptions` | OpenAI Audio (STT) | | POST | `/v1/audio/speech` | OpenAI TTS (returns audio body) | | POST | `/v1/rerank` | Cohere/Voyage-style rerank | +| POST | `/v1/classify` | Jina classify (`api.jina.ai`) | +| POST | `/v1/segment` | Jina segmenter (`segment.jina.ai`) | | POST | `/v1/moderations` | OpenAI Moderations | | GET | `/v1/models` | OpenAI | | POST | `/v1/messages/count_tokens` | Anthropic | @@ -332,7 +443,16 @@ For clients that cannot attach `Authorization: Bearer ...`, OmniRoute also accep ```bash # Rerank -POST /v1/rerank { "model": "cohere/rerank-3", "query": "...", "documents": ["..."] } +POST /v1/rerank { "model": "jina-ai/jina-reranker-v3.5", "query": "...", "documents": ["..."] } + +# Jina classify (Foundation API credentials) +POST /v1/classify { "model": "jina-embeddings-v5-text-small", "input": ["..."], "labels": ["a", "b"] } + +# Jina segmenter +POST /v1/segment { "content": "...", "return_chunks": true } + +# Jina search (s.jina.ai; provider aliases: jina-search, jina-ai, jina) +POST /v1/search { "query": "...", "provider": "jina-search" } # Moderations POST /v1/moderations { "model": "omni-moderation-latest", "input": "..." } @@ -808,7 +928,10 @@ Authorization: Bearer your-api-key Content-Type: multipart/form-data ``` -Transcribe audio files using Deepgram or AssemblyAI. +Transcribe audio files using any configured STT provider. The first path +segment selects the native provider (`openai/…`, `deepgram/…`). Gateways that +re-export another vendor's model use a qualified id +(`openrouter/deepgram/nova-3`). **Request:** @@ -816,7 +939,7 @@ Transcribe audio files using Deepgram or AssemblyAI. curl -X POST http://localhost:20128/v1/audio/transcriptions \ -H "Authorization: Bearer your-api-key" \ -F "file=@recording.mp3" \ - -F "model=deepgram/nova-3" + -F "model=openai/whisper-1" ``` **Response:** @@ -830,7 +953,10 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \ } ``` -**Supported providers:** `deepgram/nova-3`, `assemblyai/best`. +**Example model ids:** `openai/whisper-1` (requires an OpenAI key), +`openrouter/deepgram/nova-3` (requires an OpenRouter key), +`deepgram/nova-3` (requires a native Deepgram key). A bare +`deepgram/nova-3` request does **not** use OpenRouter. **Supported formats:** `mp3`, `wav`, `m4a`, `flac`, `ogg`, `webm`. @@ -1388,16 +1514,16 @@ Admin-only endpoints for operational management. Manage CLI tools that integrate with OmniRoute (antigravity, chipotle, commandCode, devin-cli, etc.). See [Provider Reference](./PROVIDER_REFERENCE.md) for the full list. -| Method | Path | Description | -| ------ | --------------------------------------- | ---------------------------------------------------------------------------------------------- | -| GET | `/api/cli-tools/all-statuses` | Status of all CLI tools (installed, version, last seen) | -| GET | `/api/cli-tools/[id]/status` | Status of a specific CLI tool (id can be: antigravity, chipotle, commandCode, devin-cli, etc.) | -| POST | `/api/cli-tools/apply` | Apply a CLI tool configuration to a provider connection | -| GET | `/api/cli-tools/backups` | List CLI tool configuration backups | -| POST | `/api/cli-tools/backups` | Create a backup of all CLI tool configurations | -| POST | `/api/cli-tools/[id]/restore` | Restore a CLI tool from a backup | -| GET | `/api/cli-tools/antigravity-mitm` | Antigravity MITM proxy status (the "antigravity-mitm" CLI tool) | -| POST | `/api/cli-tools/antigravity-mitm/alias` | Configure antigravity-mitm aliases | +| Method | Path | Description | +| ------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/api/cli-tools/all-statuses` | Status of all CLI tools (installed, version, last seen) | +| GET | `/api/cli-tools/[id]/status` | Status of a specific CLI tool (id can be: antigravity, chipotle, commandCode, devin-cli, etc.) | +| POST | `/api/cli-tools/apply` | Write a tool's generated config (`dryRun` previews; `422` + `containerEphemeralTarget` when containerized; `migration` notes a legacy Codex YAML) | +| GET | `/api/cli-tools/backups` | List CLI tool configuration backups | +| POST | `/api/cli-tools/backups` | Create a backup of all CLI tool configurations | +| POST | `/api/cli-tools/[id]/restore` | Restore a CLI tool from a backup | +| GET | `/api/cli-tools/antigravity-mitm` | Antigravity MITM proxy status (the "antigravity-mitm" CLI tool) | +| POST | `/api/cli-tools/antigravity-mitm/alias` | Configure antigravity-mitm aliases | **Auth:** Requires management session. diff --git a/docs/reference/CLI-TOOLS.md b/docs/reference/CLI-TOOLS.md index fe3f65d1e0..b9c3deac47 100644 --- a/docs/reference/CLI-TOOLS.md +++ b/docs/reference/CLI-TOOLS.md @@ -83,6 +83,17 @@ 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). +The dashboard's **apply endpoint** (`POST /api/cli-tools/apply`) enforces the +same guard: in a container, a write whose target is not bind-mounted from the +host answers **`422`** with `containerEphemeralTarget: true`, the safe error +text and a `hostSetupCommand` (e.g. `omniroute setup-opencode`) to run on the +host instead — nothing is written. `dryRun: true` keeps working in container +mode and returns the generated content + target path without touching disk, so +you can preview from the dashboard and apply on the host. This behavior is +intentional and regression-guarded by +`tests/unit/api/cli-tools/apply-container-guard.test.ts` — never "fix" a 422 +by removing the guard. + --- ## Source of Truth @@ -102,6 +113,26 @@ Each entry has these fields (defined in `src/shared/schemas/cliCatalog.ts`): Entries with `baseUrlSupport: "none"` are **not shown** in the dashboard pages — they are registered in the MITM backlog for plan 11 (see `_tasks/features-v3.8.6/refactorpages/_orchestration/_plan11-mitm-backlog.md`). +### Capability tiers (cataloged × detectable × configurable × launchable) + +Not every cataloged tool is detectable, configurable or launchable. Each tier has one +declaring source, and a drift test keeps them aligned: + +| Tier | Meaning | Declared in | +| ---------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------- | +| **Cataloged** | Appears in the dashboard catalog (name, vendor, docs, config type) | `src/shared/constants/cliTools.ts` (`CLI_TOOLS`) | +| **Detectable** | Binary/config detection, health checks, config paths | `src/shared/services/cliRuntime.ts` (`CLI_TOOLS` runtime catalog) | +| **Configurable** | Supported by `omniroute configure ` (setup recipe exists) | `bin/cli/cli-manifest.mjs` (`configure: true`) | +| **Launchable** | Supported by `omniroute run ` (env/args injection defined) | `bin/cli/cli-manifest.mjs` (`run: true`) | + +`bin/cli/cli-manifest.mjs` is the canonical executable manifest for the CLI command +surfaces: `run`, `configure` and the shell-completion generators all derive their +target lists, alias resolution (for example `kilocode`/`kilo-code`/`kilo_cli` → `kilo`) +and `--model` flag wiring from it. The drift guard +`tests/unit/cli/cli-manifest-drift.test.ts` asserts that the manifest, the runtime +catalog, the UI catalog and every consumer surface stay in sync — a target added to +one surface without the others fails the suite instead of drifting silently. + --- ## 1. CLI Code's Catalog (25 tools) @@ -318,6 +349,9 @@ npm install -g kilocode # Qwen Code npm install -g @qwen-code/qwen-code +# Google Gemini CLI (launchable via `omniroute run gemini` → /v1beta surface) +npm install -g @google/gemini-cli + # Aider pip install aider-chat @@ -384,14 +418,26 @@ Use the unified Anthropic gateway root for Claude Code. Do not append `/v1` here #### OpenAI Codex +Modern Codex (v0.137+) reads `~/.codex/config.toml` only — the old +`config.yaml` belongs to the legacy npm CLI and is silently ignored. The API +key stays in the `OMNIROUTE_API_KEY` environment variable (`env_key`), never +inside the file: + ```bash -mkdir -p ~/.codex && cat > ~/.codex/config.yaml << EOF -model: auto -apiKey: sk-your-omniroute-key -apiBaseUrl: http://localhost:20128/v1 +mkdir -p ~/.codex && cat > ~/.codex/config.toml << EOF +model_provider = "omniroute" + +[model_providers.omniroute] +name = "OmniRoute" +base_url = "http://localhost:20128/v1" +env_key = "OMNIROUTE_API_KEY" +requires_openai_auth = false EOF +export OMNIROUTE_API_KEY="sk-your-omniroute-key" ``` +Full reference (profiles, `wire_api`, context windows): [CODEX-CLI-CONFIGURATION.md](../guides/CODEX-CLI-CONFIGURATION.md). + **Test:** `codex "what is 2+2?"` --- @@ -613,10 +659,19 @@ omniroute providers list --json omniroute providers test # Test one configured connection omniroute providers test-all # Test every active connection omniroute providers validate # Local-only structural validation +omniroute providers add --credential-env PROVIDER_KEY +omniroute providers import ./providers.json --dry-run --json +omniroute providers auth # Existing OAuth flow +omniroute providers edit --default-model +omniroute providers remove --yes ``` -> `providers available` reads the OmniRoute catalog; `providers list/test/test-all/validate` -> read the local SQLite database directly and do not require the server to be running. +`providers add/import/auth/edit/remove` are API-first and therefore work against +the active local or remote context. Credential input should use +`--credential-stdin` or `--credential-env`; `--dry-run --json` reports only +redacted presence/shape. `providers available` reads the OmniRoute catalog; +`providers list/test/test-all/validate` retain their local SQLite behavior and +do not require the server to be running. ### Recovery & Reset diff --git a/docs/reference/EMBEDDINGS.md b/docs/reference/EMBEDDINGS.md new file mode 100644 index 0000000000..47a35d51a3 --- /dev/null +++ b/docs/reference/EMBEDDINGS.md @@ -0,0 +1,168 @@ +--- +title: "Embeddings client runbook" +lastUpdated: 2026-08-17 +--- + +# Embeddings client runbook + +Operator notes for `POST /v1/embeddings` when OmniRoute sits in front of +Hindsight 0.9.1 (text-only `encode(list[str])`) and Memorix 1.6.0 (Jina media +gate). Live-verified 2026-08-17 against OmniRoute 3.8.49 at +`https://omniroute.jaguar-fish.ts.net/v1`. No secrets below. + +## Working model ids + +| Client id | HTTP | Vectors | Dim | Notes | +| --- | --- | --- | --- | --- | +| `openrouter/google/gemini-embedding-2` | 200 | batch 2 → 2 | 3072 | Works without a native Gemini key | +| `openrouter/google/gemini-embedding-2-preview` | 200 | batch 2 → 2 | 3072 | Same space as the non-preview id | +| `openrouter/google/gemini-embedding-001` | 200 | batch 2 → 2 | 3072 | Listed in `GET /v1/embeddings` | +| `jina-ai/jina-embeddings-v5-omni-small` | 200 | batch 2 → 2 | 1024 | Canonical Jina omni id | +| `jina/jina-embeddings-v5-omni-small` | 200 | batch 2 → 2 | 1024 | Alias; response `model` is `jina-ai/...` | +| `jina-embeddings-v5-omni-small` | 200 | batch 2 → 2 | 1024 | Bare id also resolves | +| `jina-ai/jina-embeddings-v5-omni-nano` | 200 | 1 → 1 | **768** | Different vector space from small | + +`GET /v1/models` and `GET /v1/embeddings` listed +`jina-ai/jina-embeddings-v5-omni-small` (1024) and +`jina-ai/jina-embeddings-v5-omni-nano` (768) and +`openrouter/google/gemini-embedding-001`. They did **not** list +`openrouter/google/gemini-embedding-2` even though that id already serves. + +Do not mix nano (768-d) and small (1024-d) in one index. They are not +comparable. + +## Broken / misleading ids + +### Native Gemini Embedding 2 + +Request: + +```json +{ "model": "gemini-embedding-2", "input": ["alpha", "beta"] } +``` + +Actual (2026-08-17): HTTP **400** + +```json +{ + "error": { + "message": "No credentials for embedding provider: gemini", + "type": "invalid_request_error", + "code": "bad_request" + } +} +``` + +`gemini/gemini-embedding-2` returns the same 400. `google/gemini-embedding-2` +returns HTTP **400** `Unknown embedding provider: google` unless a custom +provider node uses the `google` prefix. + +Expected: either a native Gemini embed with a Google AI Studio key on the +`gemini` provider, or a 400 that names the working OpenRouter id. + +Repro (redact the bearer): + +```bash +curl -sS -D- https://omniroute.example/v1/embeddings \ + -H "Authorization: Bearer $OMNIROUTE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"gemini-embedding-2","input":["alpha","beta"]}' +``` + +Working substitute: + +```bash +curl -sS https://omniroute.example/v1/embeddings \ + -H "Authorization: Bearer $OMNIROUTE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"openrouter/google/gemini-embedding-2","input":["alpha","beta"]}' +``` + +Native `gemini-embedding-2` cannot succeed from GitOps alone. A Google AI +Studio key must be added as a `gemini` provider connection (dashboard or +`GEMINI_API_KEY` imported into OmniRoute). That secret is not in this repo. + +### Jina multimodal path + +`POST /v1/multimodal-embeddings` → HTTP **404** + +```json +{ + "error": { + "message": "Unknown API route: /v1/multimodal-embeddings", + "type": "not_found", + "code": "unknown_route", + "path": "/v1/multimodal-embeddings" + } +} +``` + +Use `POST /v1/embeddings` until an alias exists. + +### Jina / Memorix image object + +OmniRoute canonical image item (28×28 PNG, 784 pixels — Jina rejects 1×1): + +```json +{ + "model": "jina-ai/jina-embeddings-v5-omni-small", + "input": [ + { + "type": "image", + "source": { + "type": "base64", + "data": "", + "media_type": "image/png" + } + } + ] +} +``` + +Actual: HTTP **200**, 1 vector, 1024-d. + +Memorix 1.6.0 / Jina native shape: + +```json +{ + "model": "jina-ai/jina-embeddings-v5-omni-small", + "input": [{ "image": "data:image/png;base64," }] +} +``` + +Actual: HTTP **400** + +```json +{ + "error": { + "message": "Invalid request", + "type": "invalid_request_error", + "code": "bad_request" + } +} +``` + +`{ "text": "..." }` mixed with `{ "image": "data:..." }` is the same 400. + +## Client notes + +### Hindsight 0.9.1 + +Hindsight embeddings are text-only (`encode(list[str])`). It does not send +image objects. Point Hindsight's OpenAI-compatible embeddings base URL at +OmniRoute `/v1` and use a working id from the table above +(`jina-ai/jina-embeddings-v5-omni-small` or +`openrouter/google/gemini-embedding-2`). Do not set the model to bare +`gemini-embedding-2` unless a `gemini` API key exists on the gateway. + +### Memorix 1.6.0 + +Memorix only treats `baseUrl` matching `/jina\.ai/i` as native media. An +OmniRoute URL stays on the text-only path even when the model is Jina omni. +That gate is a Memorix client issue. Independently, OmniRoute still rejects +the Jina `{image: "data:..."}` body that Memorix would send if the gate +opened, so Jina-compatible clients cannot embed images through OmniRoute +without the canonical `{type,source}` schema. + +Use `jina-ai/jina-embeddings-v5-omni-small` for text. Do not point Memorix +`base_url` at `https://api.jina.ai` — keep OmniRoute as the only hop. diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index a47db4ed0c..f199e19fec 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -104,6 +104,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. | | `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. | | `OMNIROUTE_PROXY_FETCH_DEBUG` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Set to `"true"` to emit `[ProxyFetch]` debug logs on the Vercel relay path. Off by default to avoid leaking routing hints. | +| `PROXY_LOG_INCLUDE_IPS` | `false` | `src/lib/proxyLogger.ts` | Set to `"true"` or `"1"` to include client/egress IPs and the account prefix in the verbose `[ProxyEgress]` process-log line. Kept OFF by default so the process log does not leak IPs or the account prefix. | | `OMNIROUTE_DEBUG_COMPLETION` | _(unset)_ | `bin/cli/commands/completion.mjs` | Set to any non-empty value to emit `[omniroute completion]` diagnostics from the CLI shell-completion cache paths (read/refresh/write). Off by default — those caches fail silently so a missing/corrupt cache never breaks tab-completion. | | `BATCH_RETRY_DURATION_MS` | `86400000` (24h) | `open-sse/services/batchProcessor.ts` | Maximum retry window for individual batch items (ms). Items exceeding this duration are marked failed. | | `BATCH_BACKOFF_BASE_MS` | `5000` | `open-sse/services/batchProcessor.ts` | Base delay (ms) for exponential backoff on batch item retries. | @@ -197,6 +198,8 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_CHAT_LARGE_BODY_BYTES` | `262144` (256 KB) | `src/shared/middleware/chatBodyAdmission.ts` | Actual request bodies at or above this threshold require an atomic process-local heavyweight admission lease before JSON parsing. | | `OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES` | `52428800` (50 MB) | `src/shared/middleware/chatBodyAdmission.ts` | Chat-route hard cap enforced against bytes read during bounded ingestion, including requests with missing, invalid, or dishonest `Content-Length`; excess receives `413`. | | `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` | `1` | `src/shared/middleware/chatBodyAdmission.ts` | Maximum heavyweight chat requests admitted concurrently in one process. When capacity is unavailable, OmniRoute returns retryable `503` with `Retry-After`. | +| `OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO` | `0.75` | `src/shared/middleware/chatBodyAdmission.ts` | Heap-pressure shed ratio (`heapUsed / heap_size_limit`) for the structural admission gate (#10183, #10268). A second concurrent heavyweight request past `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` is only shed with the retryable `503` when the heap is ALSO at or above this ratio; on a healthy heap it is admitted instead. | +| `OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM` | `OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT` (default `1`) | `src/shared/middleware/chatBodyAdmission.ts` | Bounded extra capacity for the healthy-heap fast path above (#10437). Without this bound, every busy-but-healthy-heap request bypassed admission with no ceiling at all — a slow leak or a burst that never quite trips the heap-shed ratio could still pile up unlimited concurrent heavyweight work. Once this many concurrent leases are active through the healthy-heap path, further busy requests fall through to the SAME bounded-wait/shed path used under real heap pressure. `0` disables the bypass entirely. | | `OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT` | `200` | `src/shared/middleware/chatBodyAdmission.ts` | Message count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_TOOL_COUNT` | `64` | `src/shared/middleware/chatBodyAdmission.ts` | Tool count that classifies a chat request as heavyweight even when its body is below the byte threshold. | | `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. | @@ -310,10 +313,10 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp | `OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID used for dashboard quota scraping. Prefer the per-connection Dashboard field when multiple accounts are configured. | | `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go workspace ID env var used before the shorter alias. Prefer the per-connection Dashboard field when multiple accounts are configured. | | `OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` cookie used for dashboard quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | -| `OPENCODE_SYNTHESIZE_CLI_HEADERS` | `false` | `open-sse/executors/opencode.ts` | Opt-in: synthesize OpenCode CLI identity headers (User-Agent, x-opencode-client/project, request/session UUIDs) on opencode-go/zen upstream requests the client didn't send, so Cloudflare on VPS egress accepts them (#6210/#5997). Off by default (forward-only is safer). | -| `OPENCODE_USER_AGENT` | `opencode-cli/1.0.0` | `open-sse/executors/opencode.ts` | Default User-Agent used when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on and no per-provider `_USER_AGENT` override is set. Only applied to opencode executors. | -| `OPENCODE_CLIENT` | `cli` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-client` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | -| `OPENCODE_PROJECT` | `default` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-project` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | +| `OPENCODE_SYNTHESIZE_CLI_HEADERS` | `true` | `open-sse/executors/opencode.ts` | Synthesize OpenCode CLI identity headers (User-Agent, x-opencode-client/project, request/session UUIDs) on opencode-go/zen upstream requests the client didn't send, so Cloudflare on VPS egress accepts them (#6210/#5997). On by default since #10571; opt out with `false`/`0`/`no`/`off`. | +| `OPENCODE_USER_AGENT` | `opencode` | `open-sse/executors/opencode.ts` | Default User-Agent used when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on and no per-provider `_USER_AGENT` override is set. Only applied to opencode executors. | +| `OPENCODE_CLIENT` | `desktop` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-client` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | +| `OPENCODE_PROJECT` | `global` | `open-sse/executors/opencode.ts` | Value for the synthesized `x-opencode-project` header when `OPENCODE_SYNTHESIZE_CLI_HEADERS` is on. | | `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go `auth` cookie env var used before the shorter alias. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | | `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | Ollama Cloud settings URL used for quota scraping. Override for relays / test fixtures. | | `OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` cookie used for settings-page quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. | @@ -387,6 +390,9 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, | `CLI_CONTINUE_BIN` | `cn` | `src/shared/services/cliRuntime.ts` | Custom path to Continue CLI binary. | | `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. | | `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. | +| `CLI_AIDER_BIN` | `aider` | `src/shared/services/cliRuntime.ts` | Custom path to the Aider CLI binary. | +| `CLI_GOOSE_BIN` | `goose` | `src/shared/services/cliRuntime.ts` | Custom path to the Goose CLI binary. | +| `CLI_GEMINI_BIN` | `gemini` | `src/shared/services/cliRuntime.ts` | Custom path to the Google Gemini CLI binary (used by detection and `omniroute run gemini`). | | `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. | | `DEVIN_DESKTOP_VERSION` | `3.6.27` | `open-sse/executors/devin-desktop.ts` | Devin Desktop `ide_version`. Overrides must use `x.y.z` format; invalid values fall back to the verified default. | | `DEVIN_DESKTOP_EXTENSION_VERSION` | `1.48.2` | `open-sse/executors/devin-desktop.ts` | Bundled Codeium/language-server `extension_version`, distinct from Desktop `ide_version`. Overrides must use `x.y.z`; invalid values use the bundled default. | @@ -474,6 +480,7 @@ detection above). | `OMNIROUTE_ISSUE_AGENT_ENABLED` | `false` | `src/app/api/issue-agent/runs/route.ts` | Enables the offline/local Issue Agent recorded-triage endpoint. Leave disabled unless explicitly running local recorded-triage workflows. | | `OMNIROUTE_ISSUE_AGENT_TIMEOUT_MS` | _(unset)_ | `src/lib/issueAgent/execution.ts` | Timeout (ms) for a single Issue Agent recorded-triage run. Clamped to an internal maximum; falls back to the built-in default when unset or invalid. | | `OMNIROUTE_CONTEXT` | _(active context)_ | `bin/cli/program.mjs`, `bin/cli/api.mjs` | CLI remote-mode context/profile for `omniroute` commands; overrides the active context in the local contexts store. Equivalent to `--context `. | +| `OMNIROUTE_CONTEXT_KEYCHAIN_DISABLED` | `0` | `bin/cli/contexts.mjs` | Disable the optional `keytar` OS-keychain backend for CLI context credentials. When enabled, credentials remain in `config.json` mode `0600` and the CLI emits a one-time fallback warning; intended for deliberate headless/container operation. | | `OMNIROUTE_MCP_ENFORCE_SCOPES` | `true` | `open-sse/mcp-server/server.ts` | Enforce scope-based access control on MCP tool calls. | | `OMNIROUTE_MCP_SCOPES` | _(all)_ | `open-sse/mcp-server/server.ts` | Comma-separated scopes: `admin`, `combos`, `health`, `models`, `routing`, `budget`, `metrics`, `pricing`, `memory`, `skills`. | | `OMNIROUTE_MCP_COMPRESS_DESCRIPTIONS` | `false` | `open-sse/mcp-server/descriptionCompressor.ts` | Compress MCP tool descriptions before serializing the manifest. Enable values: `1`, `true`, `on`. | @@ -652,12 +659,20 @@ Recognized pattern: `{PROVIDER_ID}_API_KEY` | ------------------ | ---------- | | `DEEPSEEK_API_KEY` | DeepSeek | | `NVIDIA_API_KEY` | NVIDIA NIM | +| `JINA_AI_API_KEY` | Jina AI (Foundation API + Reader fallback) | +| `JINA_API_KEY` | Jina AI (alias for `JINA_AI_API_KEY`) | +| `GEMINI_API_KEY` | Gemini (Google AI Studio) embeddings + chat fallback | +| `GOOGLE_API_KEY` | Gemini (alias for `GEMINI_API_KEY`) | > [!NOTE] > Static `${PROVIDER}_API_KEY` entries for Groq, xAI, Mistral, Perplexity, Together AI, Fireworks, Cerebras, Cohere, Nebius, and Qianfan were removed in v3.8.0 because the runtime no longer reads them — those providers rely exclusively on Dashboard / `data/provider-credentials.json` / the encrypted DB. See the _Audit: Removed / Dead Variables_ section at the bottom of this document for the migration path. > [!TIP] > Keys set via the Dashboard are stored encrypted in SQLite and take precedence over environment variables. +> +> **Jina:** `jina-ai/…` embeddings, rerank, classify, segment, and `jina-search` do **not** bill a cluster env key when a dashboard `jina-ai` (or shared `jina-reader`) connection exists — `getProviderCredentials` is fill-first. `JINA_AI_API_KEY` / `JINA_API_KEY` are used only when no usable dashboard key exists. Call logs attribute the env fallback as `connection_id=env:JINA_AI_API_KEY`. The Reader card (`jina-reader`, `r.jina.ai`) never serves `/v1/embeddings` or `/v1/rerank`. +> +> **Gemini:** `gemini/gemini-embedding-2` (alias `google/gemini-embedding-2`) uses the dashboard `gemini` connection first. `GEMINI_API_KEY` / `GOOGLE_API_KEY` are used only when no usable dashboard key exists. Call logs attribute the env fallback as `connection_id=env:GEMINI_API_KEY`. Native multimodal traffic uses `x-goog-api-key` against `:embedContent` / `:batchEmbedContents` — N OpenAI `input` items become N vectors. --- @@ -690,7 +705,7 @@ REQUEST_TIMEOUT_MS (global override) | `REQUEST_TIMEOUT_MS` | _(unset)_ | Global shortcut — overrides both `FETCH_TIMEOUT_MS` and `STREAM_IDLE_TIMEOUT_MS` defaults. | | `FETCH_TIMEOUT_MS` | `600000` | Total HTTP request timeout for upstream provider calls. | | `STREAM_IDLE_TIMEOUT_MS` | `600000` | Max silence between SSE chunks before aborting. Extended-thinking models rarely pause >90s. | -| `OMNIROUTE_SSE_COMMENTS` | _(enabled)_ | Whether OmniRoute may emit SSE `:` comment lines (e.g. the `: keepalive` heartbeat). Set `off` to suppress comment-shaped heartbeats (no-op) for strict OpenAI-compatible clients that JSON.parse every SSE line; `data:` heartbeats are unaffected. Used by `open-sse/utils/sseHeartbeat.ts`. | +| `OMNIROUTE_SSE_COMMENTS` | _(disabled)_ | Whether OmniRoute may emit SSE `:` comment lines (e.g. the `: keepalive` heartbeat and `x-omniroute-*` metadata trailers). Disabled by default (#10524) since strict OpenAI-compatible clients JSON.parse every SSE line and crash on `:` comments; `data:` heartbeats are unaffected. Set `on`/`true`/`1`/`yes` to opt back in. Used by `open-sse/utils/sseHeartbeat.ts`. | | `STREAM_READINESS_TIMEOUT_MS` | `80000` | Time to receive the first non-ping SSE event. Inherits `REQUEST_TIMEOUT_MS` when set. | | `STREAM_READINESS_MAX_TIMEOUT_MS` | `180000` | Maximum adaptive first-event readiness window for large, tool-heavy, or high-reasoning streaming requests. | | `OMNIROUTE_AGENT_GOAL_POLICY_ENABLED` | `true` | Kill-switch for the `/goal` heuristic. Set `false`/`0`/`off` to fully disable detection — readiness timeouts and stream recovery are never elevated by request body/headers, mitigating client-controlled timeout amplification. | @@ -881,6 +896,7 @@ Automatic model pricing data synchronization from external sources. | Variable | Default | Source File | Description | | ------------------------- | ------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | | `ARENA_ELO_SYNC_ENABLED` | `true` | `src/shared/constants/featureFlagDefinitions.ts` | Periodic Arena AI leaderboard ELO sync, configurable from Dashboard Feature Flags or with `false` to opt out. | +| `MODELS_CATALOG_PREFIX_MODE` | `dual` | `src/shared/constants/featureFlagDefinitions.ts`, `src/app/api/v1/models/catalog.ts` | Prefix form used for model ids in `GET /v1/models`. `dual` advertises both the short alias prefix and the canonical provider prefix for every model (backward compatibility — roughly doubles the catalog); `alias` emits one id per model; `canonical` emits only the full provider-id prefix and omits providers whose alias already is the canonical id. Clients can override per request with `?prefix=alias`. See [API_REFERENCE](API_REFERENCE.md#model-id-prefixes-prefix). | | `ARENA_ELO_SYNC_INTERVAL` | `86400` (24h) | `src/lib/arenaEloSync.ts` | Sync interval in seconds. | --- @@ -955,6 +971,7 @@ Chrome-driven session refresh (ARP) for the Adobe Firefly web provider (`open-ss | `AWS_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Region used to construct AWS Bedrock endpoints (Kiro, audio). | | `AWS_DEFAULT_REGION` | _(unset)_ | `src/lib/providers/validation.ts`, `open-sse/handlers/audioSpeech.ts` | Fallback when `AWS_REGION` is not set. | | `CLOUDFLARE_ACCOUNT_ID` | _(unset)_ | `open-sse/executors/cloudflare-ai.ts` | Account ID for Cloudflare Workers AI. | +| `CLOUDFLARE_PLAYGROUND_CHROME_PATH` | _(unset)_ | `open-sse/executors/cloudflare-playground.ts` | Full desktop Chrome binary path for the Cloudflare AI Playground executor, used when the headless fingerprint check blocks Playwright's bundled Chromium. | | `CLOUDFLARE_API_BASE` | `https://api.cloudflare.com/client/v4` | `src/app/api/settings/proxy/cloudflare-deploy/route.ts` | Override the Cloudflare REST API base used by the proxy-pool Workers relay deployer (#4640 / 9router#1360). | | `NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT` | `omniroute-relay` | `src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx` | Default worker project name suggested in the proxy-pool "Deploy Relay" modal. | | `NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED` | `true` | `src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx` | Set to `false` to hide the Cloudflare Workers relay option from the Proxy Pool tab. | @@ -1503,9 +1520,11 @@ 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. | +<<<<<<< HEAD | `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_CHAT_VIRTUAL_LANES` | `0` (off) | `open-sse/services/admission/runtime.ts` | Adaptive runtime virtual admission lanes (#9654): master switch for the per-tenant adaptive gate (system 2). Distinct from the deprecated per-connection lane vars above (TTL_MS / MAX_SESSIONS, no-ops since #10110). Dashboard feature flag of the same name; the env var wins over the dashboard override; requires restart. | | `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. | @@ -1548,3 +1567,11 @@ Long-lived SSE consumer that mirrors OmniConductor hub tasks into the local A2A | `CONDUCTOR_HUB_TOKEN` | _(empty)_ | `src/lib/conductor/boot.ts` | Hub credential for the SSE feed — emit a `spokesperson`-kind peer on the hub (`POST /v1/peers`, admin). | | `CONDUCTOR_ORCHESTRATOR_TOKEN` | _(empty)_ | `src/lib/conductor/hubProxy.ts` | Credential for inbound A2A→hub task delegation (`POST /v1/tasks`); falls back to `CONDUCTOR_HUB_TOKEN` when unset. | | `CONDUCTOR_SPOKESPERSON_URL` | `http://127.0.0.1:7920` | `src/lib/conductor/faroProxy.ts` | Base URL of the spokesperson (Faro) service behind the dashboard chat proxy (`/api/conductor/ask`). | + +### Quota-aware scheduling + +Used by `open-sse/services/combo.ts` and `src/lib/quota/quotaScheduler.ts` for pre-request token-budget checks. Opt-in — default routing behavior is unchanged when unset. + +| Variable | Default | Source File | Description | +| --------------------------------- | -------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `OMNIROUTE_QUOTA_AWARE_ROUTING` | `0` | `open-sse/services/combo.ts` | When `1`, skip connections whose per-window token budget (`rateLimitOverrides.tpm`, table `provider_quota_state`) cannot afford the estimated request cost before dispatch. Fail-open when no budget configured. | diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index 4fc1c6bed3..7d159cef5f 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" version: 3.8.50 -lastUpdated: 2026-08-16 +lastUpdated: 2026-08-18 --- # Provider Reference > **Auto-generated** from `src/shared/constants/providers.ts` — do not edit by hand. > Regenerate with: `npm run gen:provider-reference` -> **Last generated:** 2026-08-16 +> **Last generated:** 2026-08-18 -Total providers: **341**. See category breakdown below. +Total providers: **340**. See category breakdown below. ## Categories @@ -34,7 +34,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each --- -## No-auth Providers (no key required) (11) +## No-auth Providers (no key required) (10) | ID | Alias | Name | Tags | Website | Notes | Tool calling | |----|-------|------|------|---------|-------|--------------| @@ -44,7 +44,6 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `devin-cli-agentic` | `dva` | Devin CLI Agentic Bridge | No-auth | [link](https://docs.devin.ai/work-with-devin/devin-cli) | Authentication is owned by the official Devin CLI in its isolated bridge volume. | emulated | | `duckduckgo-web` | `ddgw` | DuckDuckGo AI Chat | No-auth | [link](https://duckduckgo.com/duckchat) | No credentials required — DuckDuckGo AI Chat is anonymous and free. | emulated | | `felo-web` | `felo` | Felo | No-auth | [link](https://felo.ai) | No credentials required — Felo is a free, no-signup chat/search aggregator. | — | -| `mimocode` | `mcode` | MiMoCode (Free) | No-auth | [link](https://mimo.mi.com) | No API key required. The executor auto-generates JWT tokens via device fingerprint bootstrap. | — | | `opencode` | `oc` | OpenCode Free | No-auth | [link](https://opencode.ai) | No API key required — uses OpenCode's public free endpoint. | — | | `theoldllm` | `tllm` | The Old LLM (Free) | No-auth | [link](https://theoldllm.vercel.app) | No credentials required. The executor auto-generates access tokens via an embedded Playwright browser instance. | — | | `veoaifree-web` | `veo-free` | Veo AI Free | No-auth, video | [link](https://veoaifree.com) | No auth required. Rate limited to 6 requests/hour per IP. | — | @@ -225,8 +224,8 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `inception` | `inception` | Inception | API key | [link](https://docs.inceptionlabs.ai) | 10M free tokens on signup, no credit card required. | | `inference-net` | `inet` | Inference.net | API key | [link](https://inference.net) | $25 free credits on signup plus research grants available | | `internlm` | `internlm` | InternLM (Intern-S1) | API key | [link](https://internlm.intern-ai.org.cn/) | Free monthly quota ~1M input / 3M output tokens (~10 RPM) | -| `jina-ai` | `jina` | Jina AI | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for the Jina AI rerank API. | -| `jina-reader` | `jr` | Jina Reader | API key | [link](https://jina.ai/reader) | — | +| `jina-ai` | `jina` | Jina AI (Foundation API) | API key, embed/rerank | [link](https://jina.ai) | Bearer API key for api.jina.ai — embeddings, rerank, classify, segment, and search. Dashboard keys take precedence over JINA_AI_API_KEY. This is not the Reader / r.jina.ai card and does not fetch URLs. | +| `jina-reader` | `jr` | Jina Reader (r.jina.ai) | API key | [link](https://jina.ai/reader) | Bearer API key for r.jina.ai URL-to-markdown (/v1/web/fetch only). Does not serve /v1/embeddings or /v1/rerank. The same Jina token as Foundation API works; OmniRoute reuses a jina-ai dashboard key or JINA_AI_API_KEY when this card is empty. | | `kenari` | `kenari` | Kenari | API key | [link](https://kenari.id) | Use your Kenari API key (kn-...) in Authorization: Bearer . Fully OpenAI-compatible. API base URL: https://kenari.id/v1. | | `kie` | `kie` | KIE.AI | API key | [link](https://kie.ai) | — | | `kilo-gateway` | `kg` | Kilo Gateway | API key, aggregator | [link](https://kilo.ai) | — | @@ -429,7 +428,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each - Catalog: [`src/shared/constants/providers.ts`](../../src/shared/constants/providers.ts) - Registry (per-model details): [`open-sse/config/providerRegistry.ts`](../../open-sse/config/providerRegistry.ts) -- Executors: [`open-sse/executors/`](../../open-sse/executors/) (104 implementations) +- Executors: [`open-sse/executors/`](../../open-sse/executors/) (103 implementations) - Translators: [`open-sse/translator/`](../../open-sse/translator/) ## See Also diff --git a/docs/security/BAN_DETECTION.md b/docs/security/BAN_DETECTION.md index 015aa366cb..4f72fe7708 100644 --- a/docs/security/BAN_DETECTION.md +++ b/docs/security/BAN_DETECTION.md @@ -56,7 +56,10 @@ upstream error response → isAccountDeactivated(body): getMergedBannedSignals().some(sig => body.includes(sig)) [substring match] → match? → connection testStatus = "banned" (permanent — 1-year cooldown, never auto-recovers) - → if setting `autoDisableBannedAccounts` is on → also isActive = false + → if setting `autoDisableBannedAccounts` is on and `autoDisableBannedScope` + includes this connection (`all`, or `subscription` for OAuth/cookie/session) + → also isActive = false. Prepaid API keys stay active when scope is + `subscription`. → connection is skipped during account selection (combo QUOTA_BLOCKING statuses) ``` @@ -88,6 +91,13 @@ providers with real ban risk (ChatGPT Web, Claude Web, Codex, Muse Spark, Antigravity). An API-key provider will only trip the detector if its error body literally contains one of the substrings. +`autoDisableBannedScope` (`all` | `subscription`, default `all`) controls whether +a match also flips `isActive=false`. `subscription` means login-style seats +(paid subscriptions and free accounts, including web-cookie sessions). It still +records `testStatus=banned` for prepaid API keys but leaves them in the routing +pool. The durable design is a per-provider and per-account override; the global +enum is the first cut. + ## Custom banned keywords Add or remove keywords in **Security → Banned Keywords** (persisted as the global @@ -118,8 +128,9 @@ own). An operator must clear them explicitly: `active` and clears the error fields. 2. **Re-authenticate / edit credentials** — for OAuth providers, re-run the login / refresh flow; provider create/import routes set `isActive = true`. -3. **Re-enable the connection** — if `autoDisableBannedAccounts` set - `isActive = false`, toggle it back on after fixing the account. +3. **Re-enable the connection** — if auto-disable set `isActive = false` + (scope `all`, or `subscription` for an OAuth/cookie/session connection), + toggle it back on after fixing the account. There is no separate "clear ban flag" button — recovery is re-test, re-auth, or re-enable, matching the general terminal-state rule in @@ -131,6 +142,7 @@ re-enable, matching the general terminal-state rule in | --- | --- | | Signal tables + match | `open-sse/services/accountFallback.ts` | | Terminalization / persistence | `src/sse/services/auth.ts` (`markAccountUnavailable`, `resolveTerminalConnectionStatus`, `clearAccountError`) | +| Auto-disable scope | `src/shared/utils/autoDisableBanned.ts`, `src/sse/services/autoDisableBannedAccount.ts` | | Inline classification | `open-sse/handlers/chatCore.ts`, `open-sse/services/errorClassifier.ts` | | Terminal-state recovery exclusion | `src/lib/quota/connectionRecovery.ts` | | Custom-keyword runtime load | `src/lib/config/runtimeSettings.ts` (`setCustomBannedSignals`) | diff --git a/docs/security/GUARDRAILS.md b/docs/security/GUARDRAILS.md index dd42beb159..f20cb80527 100644 --- a/docs/security/GUARDRAILS.md +++ b/docs/security/GUARDRAILS.md @@ -93,6 +93,19 @@ describe prompt, steering the description toward what the user actually asked (codex-vision-proxy pattern) and asking the vision model to transcribe visible text. With the flag off — or no user text — the base prompt is used unchanged. +The describe self-loop's own OpenAI-compatible request (`callVisionModelSingle()` +in `visionBridgeHelpers.ts`) always requests `image_url.detail: "high"` — +unconditionally, for every caller/provider, not gated on any client signal. +Low-detail sampling degrades OCR accuracy for exactly the text-transcription +task this prompt asks for, so the describe call itself always asks for high +detail regardless of what detail level the original inbound request used. This +only affects the internal describe request body; it does not change how +OmniRoute forwards the caller's own `image_url.detail` on the primary request — +that default is applied separately, and only for detected OpenCode clients, in +`defaultImageDetail()` (`open-sse/handlers/chatCore/upstreamBody.ts`). The +Anthropic wire-format branch of the describe self-loop has no `detail` field +and is unaffected by either default. + #### Describe output cap (`modalityBridgeVisionMaxChars`) | Key | Default | Range | @@ -308,11 +321,71 @@ explicit default stream is preferred before the deterministic lowest-index fallback. Videos are limited to 600 seconds, 8,192 pixels per dimension, and 33,554,432 source pixels. FFmpeg samples 1–16 midpoint JPEG frames, scales down the long edge to at most 1,024 pixels without upscaling smaller inputs, and -never receives a URL. +never receives a URL. Sampling is `uniform` by default. The optional +`scene_aware` and experimental `segment_aware` policies perform one additional +fixed FFmpeg pass over the already validated local stream, select bounded +`showinfo` scene timestamps, and fall back deterministically to the same +uniform midpoints on detector failure, timeout, malformed output, or an empty +candidate set. Segment-aware mode allocates midpoint samples proportionally to +the validated scene intervals. The hard 16-frame cap is +applied after selection in every policy. A caller may optionally provide a +finite focus window (`start`/`end` seconds); bounds are clamped to the media +duration, reversed or non-finite windows are rejected, and all sampling +policies are performed only inside the normalized interval. The resulting +window is included in sampling metadata and in the untrusted description +prefix so downstream models can distinguish a focused excerpt from the full +timeline. Each frame is limited to 4 MiB, all raw frames together to 23 MiB, and the serialized broker response to 32 MiB. A private temporary directory is removed in `finally`. OmniRoute does not bundle FFmpeg and does not accept a custom -executable path. +executable path. Before captioning, the bridge applies a conservative visual +deduplication pass: each JPEG is reduced to a 16×16 grayscale buffer and is +compared only with the last frame retained, using a fixed similarity threshold +of 0.04 — a deliberate constant chosen for predictability, not a runtime +setting. The first and final timeline frames +are always retained; comparator or decoder errors fail open and keep coverage. +The output metadata reports how many frames were dropped. + +An explicitly marked video part may request a timestamped contact sheet. The +bridge builds at most a 4-column, 16-frame JPEG grid and labels the resulting +observation with every source timestamp. If `sharp` cannot decode or compose +the grid, the bridge falls back to the individual JPEG frames; a client abort +still propagates through the sheet operation. + +Callers may attach an optional `transcript.cues` array to a supported video +part when they already possess aligned text. Each cue must carry `text`, a +finite `start`/`end` interval inside the probed duration, and a whitelisted +`source` (`client`, `embedded`, or `audio-bridge`); `confidence` defaults to +`1` and must remain between `0` and `1`. Exact duplicate cues are collapsed. +OmniRoute never starts transcription from this metadata: validated cues are +copied into the described result with source, confidence, and interval, and +are rendered as untrusted observations alongside the frame captions. Invalid, +out-of-range, or provenance-free text is rejected rather than mixed into the +caption stream. + +An advanced caller may provide an already-authorized `audioTranscript` track +for the same video. The fusion seam runs visual and audio observations under +one deadline and abort signal, orders them on a common timeline, collapses +exact duplicates, and reports a partial result when only one side succeeds. +An invalid `audioTranscript` degrades to that partial result — the visual +description is kept and the audio branch records a sanitized failure code — +instead of failing the whole video. Per-branch availability, the partial flag, +and the sanitized failure codes are preserved in the described result, in the +guardrail metadata (`audioFusionRuns`/`audioFusionPartials`/ +`audioFusionFailureCodes`), in the result-cache metadata, and in the bridge +fusion counters. The default Video Bridge path does not invoke speech-to-text +or download a second media copy; without that explicit track, it remains +video-only. + +The internal `/api/modality-bridge/video/drilldown` lifecycle is a separate, +loopback/token-authenticated cache. It stores at most 16 JPEG frames per entry, +keeps entries isolated by session and video reference, expires them after ten +minutes, and supports bounded `start`/`end` reads or explicit session deletion. +Besides the per-entry limits, the cache enforces a global 256 MiB decoded-byte +budget: least-recently-used entries are evicted until new content fits, and an +entry larger than the whole budget is rejected outright. +It only slices materialized frames and cannot increase the cost of the primary +video request. Frames are captioned sequentially with the configured Video model. An empty Video override inherits the Vision setting; if both are empty, the Vision @@ -324,7 +397,11 @@ include the JPEG bytes, prompt, timestamp, and effective model; only successful captions are cached. Cache entries retain the actual successful producer model, including a fallback model; the bridge reports `mixed` when different frames were produced by different models. A cache hit reuses that producer identity -instead of relabeling it as the requested routing plan. +instead of relabeling it as the requested routing plan. The whole-video result +cache is keyed on every input that changes the output — prompt, effective +model, sampling policy, frame count, focus window, `transcript`, +`audioTranscript`, and the contact-sheet flag — so changing any of those +dimensions is a cache miss, never a stale reuse. The guardrail extracts every supported video part but describes no more than `modalityBridgeVideoMaxVideos`. For a target proven to have @@ -337,13 +414,14 @@ to raw media. Runtime settings are DB-backed and Zod-validated: -| Key | Default | Range / behavior | -| ------------------------------- | -------- | ------------------------------- | -| `modalityBridgeVideoEnabled` | `false` | Optional runtime, opt-in | -| `modalityBridgeVideoModel` | `""` | Inherit the Vision Bridge model | -| `modalityBridgeVideoFrameCount` | `8` | 1–16 | -| `modalityBridgeVideoMaxVideos` | `1` | 1–4 | -| `modalityBridgeVideoTimeout` | `120000` | 1000–120000 ms | +| Key | Default | Range / behavior | +| ----------------------------------- | ----------- | --------------------------------------------------------------------------------------------------- | +| `modalityBridgeVideoEnabled` | `false` | Optional runtime, opt-in | +| `modalityBridgeVideoModel` | `""` | Inherit the Vision Bridge model | +| `modalityBridgeVideoFrameCount` | `8` | 1–16 | +| `modalityBridgeVideoSamplingPolicy` | `"uniform"` | `uniform`, `scene_aware`, or proportional `segment_aware`; detector failure falls back to `uniform` | +| `modalityBridgeVideoMaxVideos` | `1` | 1–4 | +| `modalityBridgeVideoTimeout` | `120000` | 1000–120000 ms | Legacy persisted Video timeout values above 120 seconds are clamped to the broker deadline; new settings writes above that limit are rejected. @@ -582,7 +660,8 @@ Audio uses `modalityBridgeAudioEnabled`, `modalityBridgeAudioModel`, keys were introduced with the Modality Bridge schema. Video uses `modalityBridgeVideoEnabled`, `modalityBridgeVideoModel`, -`modalityBridgeVideoFrameCount`, `modalityBridgeVideoMaxVideos`, and +`modalityBridgeVideoFrameCount`, `modalityBridgeVideoSamplingPolicy`, +`modalityBridgeVideoMaxVideos`, and `modalityBridgeVideoTimeout`, plus the shared `modalityBridgeCache*` settings. It is disabled by default because FFmpeg/ffprobe are optional operational dependencies and frame captioning adds latency and model cost. diff --git a/electron/package-lock.json b/electron/package-lock.json index 262e136645..4e917b066a 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -12,7 +12,7 @@ "electron-updater": "^6.8.9" }, "devDependencies": { - "electron": "^43.3.0", + "electron": "^43.4.0", "electron-builder": "^26.15.3" }, "engines": { @@ -297,45 +297,6 @@ "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", @@ -1130,15 +1091,6 @@ "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", @@ -1415,9 +1367,9 @@ } }, "node_modules/electron": { - "version": "43.3.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-43.3.0.tgz", - "integrity": "sha512-nLlvu0WFjftWsSaTkV2B/c4NDuJBspTyXu8vKSQ6vLvFt8uG3NgN49LLKcXddwX0GqVvAQDhciWp+4xOdTdhew==", + "version": "43.4.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.4.0.tgz", + "integrity": "sha512-3qxGF0CeQbiox5oWV1JlbWGQ1VerbmDhTFqW4sJ8h7uqTHniFYPObXJcDna0DMh32et0fFyKzz0YY8lJv3t5jg==", "dev": true, "license": "MIT", "dependencies": { @@ -1459,19 +1411,6 @@ "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", @@ -1506,66 +1445,6 @@ "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", @@ -2480,20 +2359,6 @@ "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", @@ -2757,36 +2622,6 @@ "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", @@ -2981,21 +2816,6 @@ "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", @@ -3251,21 +3071,6 @@ "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 4d475a4076..a3793de8fa 100644 --- a/electron/package.json +++ b/electron/package.json @@ -28,7 +28,7 @@ "electron-updater": "^6.8.9" }, "devDependencies": { - "electron": "^43.3.0", + "electron": "^43.4.0", "electron-builder": "^26.15.3" }, "overrides": { diff --git a/llm.txt b/llm.txt index f99c209828..76c556e448 100644 --- a/llm.txt +++ b/llm.txt @@ -1,6 +1,6 @@ # OmniRoute -> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 341 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. +> OmniRoute is a free, open-source AI Gateway that acts as a universal API proxy for multi-provider LLMs. It provides smart routing, automatic fallback, load balancing, and format translation across 340 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (109 tools), A2A v0.3 protocol, Memory/Skills systems, Cloud Agents (codex, cursor, devin, jules), Guardrails framework, and an Electron desktop app. ## Overview @@ -14,7 +14,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo - **Runtime:** Node.js `>=22.0.0 <23 || >=24.0.0 <27`, ES Modules (`"type": "module"`) - **Framework:** Next.js 16 (App Router) with TypeScript 6 -- **Database:** SQLite via better-sqlite3 (local, zero-config, 150 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 153 migrations) - **State management:** Zustand (client), SQLite (server persistence) - **UI:** React 19, Tailwind CSS 4, Recharts for analytics, @lobehub/icons for 130+ provider SVG icons - **Auth:** OAuth 2.0 (PKCE) for providers, bcrypt for local user auth @@ -165,7 +165,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ └── manager.ts # MITM proxy manager │ ├── shared/ # Shared utilities, components, and constants │ │ ├── components/ # Reusable UI components (Card, Badge, Button, Modal, Sidebar, ProviderIcon, etc.) -│ │ ├── constants/ # Provider definitions (341), model lists, pricing, routing strategies, MCP scopes +│ │ ├── constants/ # Provider definitions (340), model lists, pricing, routing strategies, MCP scopes │ │ ├── contracts/ # Shared API contracts │ │ ├── hooks/ # React hooks │ │ ├── middleware/ # Shared middleware utilities @@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **341 AI providers** with automatic format translation +- **340 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **18 routing strategies**: priority, weighted, round-robin, fill-first, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -434,7 +434,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 4. **Environment variables:** All configuration is in `.env` (from `.env.example`). Key vars: `PORT`, `NEXT_PUBLIC_BASE_URL`, `API_KEY`, `ADMIN_PASSWORD`. -5. **Database layer:** Operations go through `src/lib/db/` modules (117 domain-specific files, 150 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. +5. **Database layer:** Operations go through `src/lib/db/` modules (120 domain-specific files, 153 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. @@ -475,7 +475,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool ## v3.8.x Highlights -- **341-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add +- **340-provider catalog** with 90+ free tiers, one-click account imports, and bulk key add - **19 routing strategies** — including `fusion` (parallel panel + judge synthesis), `pipeline`, `reset-aware`, `reset-window`, `headroom`, and `context-relay` - **14-factor Auto-Combo scoring** with bandit exploration and progressive cooldown - **MCP server expanded to 109 tools / 33 scopes** (canonical + memory/skill/agentSkill/githubSkill/pool/notion/obsidian/localCorpus/gamification/plugin modules) diff --git a/open-sse/config/agyModels.ts b/open-sse/config/agyModels.ts index 51c4f60f22..14f9e0490c 100644 --- a/open-sse/config/agyModels.ts +++ b/open-sse/config/agyModels.ts @@ -106,6 +106,18 @@ export const AGY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, + // Gemini 3.7 Flash: single callable public model (upstream exposes only + // gemini-3.7-flash-tiered; suffixed tier ids 404). One entry so it does not + // collide under the #3696 public-id uniqueness invariant. + { + id: "gemini-3.7-flash", + name: "Gemini 3.7 Flash", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, { id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash Lite", diff --git a/open-sse/config/anthropicHeaders.ts b/open-sse/config/anthropicHeaders.ts index cf6710c4fe..a030cd1c4a 100644 --- a/open-sse/config/anthropicHeaders.ts +++ b/open-sse/config/anthropicHeaders.ts @@ -6,6 +6,7 @@ import { CLAUDE_CODE_SDK_PACKAGE_VERSION, getClaudeCodeUserAgent, } from "@/shared/constants/claudeCodeClient"; +import { modelSupportsContext1mBeta } from "../config/context1m.ts"; export const ANTHROPIC_VERSION_HEADER = "2023-06-01"; @@ -70,11 +71,21 @@ export const FORWARDABLE_CLIENT_BETAS = Object.freeze([ * case-insensitive). The client beta is added only if it is on `allow`, so this * never forces betas the client did not request nor leaks betas the backend * rejects. See #3974 (tool-search-tool dropped on the Claude OAuth path). + * + * `model` (optional) gate: when a resolved upstream model is supplied and it does + * NOT support the long-context beta, `context-1m-2025-08-07` is dropped from the + * merged allowlist instead of being forwarded blind. Combo/fallback + * can re-route a request whose client negotiated `[1m]` for a more capable sibling + * onto a model that does not qualify (e.g. a Haiku) — Anthropic rejects the beta + * there with "long context beta is not yet available for this subscription" + * (#10119). When no model is supplied (legacy callers without model resolution), + * the prior forwarding behavior is preserved. */ export function mergeClientAnthropicBeta( base: string, clientBeta: string | null | undefined, - allow: readonly string[] = FORWARDABLE_CLIENT_BETAS + allow: readonly string[] = FORWARDABLE_CLIENT_BETAS, + model?: string | null ): string { const baseList = base .split(",") @@ -82,7 +93,14 @@ export function mergeClientAnthropicBeta( .filter(Boolean); if (typeof clientBeta !== "string" || !clientBeta.trim()) return baseList.join(","); const seen = new Set(baseList.map((s) => s.toLowerCase())); - const allowSet = new Set(allow.map((s) => s.toLowerCase())); + const allowList = allow + .map((s) => s.toLowerCase()) + .filter((lower) => { + if (lower !== "context-1m-2025-08-07") return true; + if (model === undefined || model === null || model === "") return true; + return modelSupportsContext1mBeta(model); + }); + const allowSet = new Set(allowList); for (const token of clientBeta .split(",") .map((s) => s.trim()) diff --git a/open-sse/config/antigravityModelAliases.ts b/open-sse/config/antigravityModelAliases.ts index 80e066592d..eeacbdf8e3 100644 --- a/open-sse/config/antigravityModelAliases.ts +++ b/open-sse/config/antigravityModelAliases.ts @@ -124,6 +124,18 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ supportsVision: true, toolCalling: true, }, + // Gemini 3.7 Flash: Antigravity's live catalog exposes a single upstream id + // gemini-3.7-flash-tiered; the suffixed tier ids 404 upstream. Kept as one + // callable public model so it does not collide with the #3696 uniqueness invariant. + { + id: "gemini-3.7-flash", + name: "Gemini 3.7 Flash", + contextLength: 1048576, + maxOutputTokens: 65536, + supportsReasoning: true, + supportsVision: true, + toolCalling: true, + }, { id: "gemini-3.1-flash-lite", name: "Gemini 3.1 Flash Lite", @@ -163,6 +175,12 @@ export const ANTIGRAVITY_PUBLIC_MODELS = Object.freeze([ ]); export const ANTIGRAVITY_MODEL_ALIASES = Object.freeze({ + // Gemini 3.7 Flash: the live catalog (fetchAvailableModels on daily-cloudcode-pa) + // exposes a single upstream id `gemini-3.7-flash-tiered`; the agy CLI maps all + // display tiers (high/medium/low) to it. Verified 200 OK with thinking_level and + // thinkingBudget configs. The suffixed ids 404 upstream ("Requested entity was not found"). + // Exposed as ONE callable model (see #3696: public ids must be unique upstream ids). + "gemini-3.7-flash": "gemini-3.7-flash-tiered", // gemini-3.1-pro-low is not aliased: the upstream accepts it verbatim. // gemini-3.1-pro-high: the discovery slot returns HTTP 400 on v1internal; // the live upstream id is gemini-pro-agent (see ANTIGRAVITY_PUBLIC_MODELS). diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 06d32d93af..00e35cad6a 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -711,6 +711,85 @@ export function parseTranslationModel(modelStr: string | null, dynamicProviders? return parseAudioModel(modelStr, AUDIO_TRANSLATION_PROVIDERS, dynamicProviders); } +export interface AudioProviderMatch { + provider: string; + model: string; + config: AudioProvider; +} + +/** + * Candidate model ids to try when the prefix-matched provider has no credentials. + * Includes the raw request string (a gateway may list `deepgram/nova-3` as its + * own model id) plus the parsed native id and `provider/model`. + */ +export function audioModelAliasCandidates( + originalModel: string, + failedProvider: string, + resolvedModel: string | null +): string[] { + const candidates = [originalModel]; + if (resolvedModel) { + candidates.push(resolvedModel); + candidates.push(`${failedProvider}/${resolvedModel}`); + } + return [...new Set(candidates.filter(Boolean))]; +} + +/** + * Find another registry provider that lists one of the candidate model ids. + * Used when `deepgram/nova-3` prefix-matches native Deepgram but only a + * gateway such as OpenRouter has credentials for that model id. + */ +export function findAlternateAudioProvider( + registry: Record, + failedProvider: string, + candidates: string[] +): AudioProviderMatch | null { + const seen = new Set(); + for (const candidate of candidates) { + if (!candidate || seen.has(candidate)) continue; + seen.add(candidate); + for (const [providerId, config] of Object.entries(registry)) { + if (providerId === failedProvider) continue; + if (config.models.some((m) => m.id === candidate)) { + return { provider: providerId, model: candidate, config }; + } + } + } + return null; +} + +/** Qualified catalog ids (`gateway/model`) that list the same nested model. */ +export function listAlternateAudioModelIds( + registry: Record, + failedProvider: string, + candidates: string[] +): string[] { + const ids: string[] = []; + const seen = new Set(); + for (const candidate of candidates) { + if (!candidate) continue; + for (const [providerId, config] of Object.entries(registry)) { + if (providerId === failedProvider) continue; + if (!config.models.some((m) => m.id === candidate)) continue; + const id = `${providerId}/${candidate}`; + if (seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + } + return ids; +} + +export function missingAudioProviderCredentialsMessage( + provider: string, + alternateIds: string[] = [] +): string { + const base = `No credentials for provider: ${provider}`; + if (alternateIds.length === 0) return base; + return `${base}. The catalog also lists this model as ${alternateIds.join(", ")}`; +} + /** * Get all audio models as a flat list */ diff --git a/open-sse/config/cliFingerprints.ts b/open-sse/config/cliFingerprints.ts index b219cd1363..f97fa41aad 100644 --- a/open-sse/config/cliFingerprints.ts +++ b/open-sse/config/cliFingerprints.ts @@ -272,6 +272,7 @@ function stripInternalBodyFields(body: unknown): unknown { delete record._claudeCodeRequiresLowercaseToolNames; delete record._nativeCodexPassthrough; delete record._nativeXaiResponsesPassthrough; + delete record._nativeOpenAICompatibleResponsesPassthrough; delete record._omnirouteResponsesStore; return body; } diff --git a/open-sse/config/context1m.ts b/open-sse/config/context1m.ts new file mode 100644 index 0000000000..dab5c405b1 --- /dev/null +++ b/open-sse/config/context1m.ts @@ -0,0 +1,39 @@ +/** + * Model eligibility for the `context-1m-2025-08-07` long-context `anthropic-beta`. + * + * Only a subset of Claude models qualify for the 1M-context beta. Forwarding the + * beta to a non-qualifying model (e.g. claude-haiku-4-5-20251001) is a hard 400 + * from the Messages API: "long context beta is not yet available for this + * subscription". A client can negotiate the beta for one member of a combo and + * have the SAME request re-routed (combo/fallback) to a less capable sibling, so + * beta forwarding must be gated on the RESOLVED target model — never blind. + * + * Neutral module (no imports) so both `anthropicHeaders.ts` (the merge path) and + * `claudeCodeCompatible.ts` (the `[1m]`-suffix path) share one source of truth + * without importing each other. + */ +export const CONTEXT_1M_SUPPORTED_MODELS = [ + "claude-fable-5", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", +] as const; + +/** + * True when the (resolved upstream) model qualifies for the long-context beta. + * Normalizes case and strips a trailing dated alias (`-20251001`) so both bare and + * dated model ids match. SHA-256 of the reference implementation in + * `claudeCodeCompatible.ts` (moved here). + */ +export function modelSupportsContext1mBeta(model: string | null | undefined): boolean { + const normalizedModel = String(model || "") + .trim() + .toLowerCase() + .replace(/-\d{8}$/, ""); + + return CONTEXT_1M_SUPPORTED_MODELS.some( + (supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`) + ); +} \ No newline at end of file diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index 1603f45bc6..e907e32509 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -241,6 +241,16 @@ export const EMBEDDING_PROVIDERS: Record = { name: "Gemini Embedding 001 (OpenRouter)", dimensions: 768, }, + { + id: "google/gemini-embedding-2", + name: "Gemini Embedding 2 (OpenRouter)", + dimensions: 3072, + }, + { + id: "google/gemini-embedding-2-preview", + name: "Gemini Embedding 2 Preview (OpenRouter)", + dimensions: 3072, + }, ], }, @@ -254,13 +264,13 @@ export const EMBEDDING_PROVIDERS: Record = { { id: "gemini-embedding-2", name: "Gemini Embedding 2", - dimensions: 768, + dimensions: 3072, modalities: ["text", "image", "audio", "video", "document"], }, { id: "gemini-embedding-2-preview", name: "Gemini Embedding 2 Preview", - dimensions: 768, + dimensions: 3072, modalities: ["text", "image", "audio", "video", "document"], }, { id: "gemini-embedding-001", name: "Gemini Embedding 001", dimensions: 768 }, @@ -405,6 +415,28 @@ const EMBEDDING_PROVIDER_ALIASES: Record = { voyage: "voyage-ai", }; +/** Family name used by clients; Jina's public SKU is omni-small. */ +const EMBEDDING_MODEL_ALIASES: Record = { + "jina-embeddings-v5-omni": "jina-embeddings-v5-omni-small", + // Live native catalog is gemini/gemini-embedding-2. Clients that send the + // OpenRouter-style google/ prefix still resolve to the Gemini provider — + // do not steal a custom provider_node whose prefix is `google`. + "google/gemini-embedding-2": "gemini/gemini-embedding-2", + "google/gemini-embedding-2-preview": "gemini/gemini-embedding-2-preview", +}; + +function applyEmbeddingModelAliases(modelStr: string): string { + for (const [alias, canonical] of Object.entries(EMBEDDING_MODEL_ALIASES)) { + if (modelStr === alias) return canonical; + // Slash-containing aliases are exact-match only so + // openrouter/google/gemini-embedding-2 stays on OpenRouter. + if (!alias.includes("/") && modelStr.endsWith(`/${alias}`)) { + return `${modelStr.slice(0, -alias.length)}${canonical}`; + } + } + return modelStr; +} + function resolveEmbeddingProviderId(providerId: string): string { return EMBEDDING_PROVIDER_ALIASES[providerId] || providerId; } @@ -442,6 +474,7 @@ export function parseEmbeddingModel( dynamicProviders?: EmbeddingProvider[] ): { provider: string | null; model: string | null } { if (!modelStr) return { provider: null, model: null }; + modelStr = applyEmbeddingModelAliases(modelStr); // Check for "provider/model" format const slashIdx = modelStr.indexOf("/"); diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 3d43bdd020..0ce4a04002 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -26,6 +26,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "agy", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6 (Thinking)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-3.1-pro-low", displayName: "Gemini 3.1 Pro (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-pro-agent", displayName: "Gemini 3.1 Pro (High)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, + { provider: "agy", modelId: "gemini-3.7-flash", displayName: "Gemini 3.7 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-3.6-flash-high", displayName: "Gemini 3.6 Flash (High)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-3.6-flash-medium", displayName: "Gemini 3.6 Flash (Medium)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, { provider: "agy", modelId: "gemini-3.6-flash-low", displayName: "Gemini 3.6 Flash (Low)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "agy", tos: "avoid" }, @@ -368,7 +369,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "qoder", modelId: "deepseek-v4-pro", displayName: "DeepSeek-V4-Pro", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "qoder", tos: "caution" }, { provider: "qoder", modelId: "deepseek-v4-flash", displayName: "DeepSeek-V4-Flash", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "qoder", tos: "caution" }, { provider: "qoder", modelId: "minimax-m3", displayName: "MiniMax-M3", monthlyTokens: 0, creditTokens: 1000000, freeType: "one-time-initial", poolKey: "qoder", tos: "caution" }, - { provider: "qwen-web", modelId: "qwen3.8-max-preview", displayName: "Qwen3.8 Max Preview", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" }, + { provider: "qwen-web", modelId: "qwen3.8-max", displayName: "Qwen3.8 Max", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" }, { provider: "qwen-web", modelId: "qwen3.7-max", displayName: "Qwen3.7 Max", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" }, { provider: "qwen-web", modelId: "qwen3.7-plus", displayName: "Qwen3.7 Plus", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" }, { provider: "qwen-web", modelId: "qwen3.6-plus", displayName: "Qwen3.6 Plus", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "qwen-web", tos: "avoid" }, diff --git a/open-sse/config/imageRegistry.ts b/open-sse/config/imageRegistry.ts index b383895061..3e24139e85 100644 --- a/open-sse/config/imageRegistry.ts +++ b/open-sse/config/imageRegistry.ts @@ -16,6 +16,7 @@ import { ADOBE_FIREFLY_IMAGE_ROUTING_ALIASES, toRegistryImageModels, } from "../services/adobeFireflyModels.ts"; +import { AI_HORDE_IMAGE_PROVIDER } from "./providers/registry/aihorde/imageModels.ts"; interface ImageModelEntry { id: string; @@ -247,6 +248,26 @@ export const IMAGE_PROVIDERS: Record = { supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], }, + // #10466: Gemini Web session image generation (Nano Banana). Same + // web-cookie transport as the gemini-web chat provider — the handler + // drives the session executor in image mode and extracts the generated + // asset URLs from the StreamGenerate frames. + "gemini-web": { + id: "gemini-web", + alias: "gweb", + baseUrl: "https://gemini.google.com/app", + authType: "apikey", + authHeader: "cookie", + format: "gemini-web", + // `-web` suffix on purpose: the bare `nano-banana` id is owned by + // adobe-firefly (operator decision 2026-07-31, pinned by the + // cheaperinference-image-models guard). parseImageModel's bare-model scan + // walks providers in insertion order, so a bare `nano-banana` here would + // steal that resolution. Keep this id distinct. + models: [{ id: "nano-banana-web", name: "Nano Banana (Gemini Web Image)" }], + supportedSizes: ["1024x1024", "1024x1536", "1536x1024"], + }, + "microsoft-designer-web": { id: "microsoft-designer-web", alias: "msdesigner", @@ -841,6 +862,7 @@ export const IMAGE_PROVIDERS: Record = { // still pass supported 4K dimensions through the permissive request schema. supportedSizes: ["1024x1024", "2048x2048"], }, + aihorde: AI_HORDE_IMAGE_PROVIDER, }; /** diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 91584babc9..01395e7dd8 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -3,7 +3,6 @@ import { unorouterProvider } from "./registry/unorouter/index.ts"; import { aimlapiProvider } from "./registry/aimlapi/index.ts"; import { byteplusProvider } from "./registry/byteplus/index.ts"; -import { mimocodeProvider } from "./registry/mimocode/index.ts"; import { ollama_cloudProvider } from "./registry/ollama-cloud/index.ts"; import { syntheticProvider } from "./registry/synthetic/index.ts"; import { ideogramProvider } from "./registry/ideogram/index.ts"; @@ -32,6 +31,7 @@ import { difyProvider } from "./registry/dify/index.ts"; import { ovhcloudProvider } from "./registry/ovhcloud/index.ts"; import { claudeProvider } from "./registry/claude/index.ts"; import { claude_webProvider } from "./registry/claude/web/index.ts"; +import { cloudflarePlaygroundProvider } from "./registry/cloudflare-playground/index.ts"; import { bedrockProvider } from "./registry/bedrock/index.ts"; import { inner_aiProvider } from "./registry/inner-ai/index.ts"; import { qoderProvider } from "./registry/qoder/index.ts"; @@ -291,6 +291,7 @@ export const REGISTRY: Record = { ovhcloud: ovhcloudProvider, claude: claudeProvider, "claude-web": claude_webProvider, + "cloudflare-playground": cloudflarePlaygroundProvider, bedrock: bedrockProvider, "inner-ai": inner_aiProvider, qoder: qoderProvider, @@ -470,7 +471,6 @@ export const REGISTRY: Record = { venice: veniceProvider, kiro: kiroProvider, byteplus: byteplusProvider, - mimocode: mimocodeProvider, wafer: waferProvider, openadapter: openadapterProvider, dit: ditProvider, diff --git a/open-sse/config/providers/registry/agy/index.ts b/open-sse/config/providers/registry/agy/index.ts index 6aab7fe3d9..661f3f8ab7 100644 --- a/open-sse/config/providers/registry/agy/index.ts +++ b/open-sse/config/providers/registry/agy/index.ts @@ -25,4 +25,5 @@ export const agyProvider: RegistryEntry = { }, models: [...AGY_PUBLIC_MODELS], passthroughModels: true, + liveCatalogAuthoritative: false, }; diff --git a/open-sse/config/providers/registry/aihorde/imageModels.ts b/open-sse/config/providers/registry/aihorde/imageModels.ts new file mode 100644 index 0000000000..be04ce18ae --- /dev/null +++ b/open-sse/config/providers/registry/aihorde/imageModels.ts @@ -0,0 +1,26 @@ +/** + * AI Horde image-generation provider entry. + * + * Chat still goes through oai.aihorde.net. Image jobs use the native Horde + * async API (`/v2/generate/async`). `models` is a live getter so + * imageRegistry stays under the file-size cap and zero-worker names are + * never advertised. + */ +import { getCachedAiHordeImageCatalogEntries } from "../../../../services/aihordeImageCatalog.ts"; + +export const AI_HORDE_IMAGE_PROVIDER = { + id: "aihorde", + alias: "horde", + baseUrl: "https://aihorde.net/api", + authType: "apikey", + authHeader: "apikey", + format: "aihorde", + get models() { + return getCachedAiHordeImageCatalogEntries().map((entry) => ({ + id: entry.id.startsWith("aihorde/") ? entry.id.slice("aihorde/".length) : entry.id, + name: entry.name, + inputModalities: entry.inputModalities, + })); + }, + supportedSizes: ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"], +}; diff --git a/open-sse/config/providers/registry/aihorde/index.ts b/open-sse/config/providers/registry/aihorde/index.ts index 054a62ba15..31a118827e 100644 --- a/open-sse/config/providers/registry/aihorde/index.ts +++ b/open-sse/config/providers/registry/aihorde/index.ts @@ -17,9 +17,14 @@ import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; * the free catalog registers it as `recurring-uncapped` (never summed into * the token headline) rather than inventing an RPM/RPD figure. * - * Model list changes as workers come and go, so the live catalog is fetched via - * passthrough; the entries below are the ones that have carried steady worker - * threads and only serve as a fallback when discovery fails. + * Chat model list changes as workers come and go, so the live chat catalog is + * fetched via passthrough; the entries below are the ones that have carried + * steady worker threads and only serve as a fallback when discovery fails. + * + * Image models are a separate native Horde API (`/v2/generate/async`). They + * are discovered by polling `/v2/status/models?type=image` and only advertised + * while `count > 0`. An optional registered API key is stored as a normal + * connection and sent as the Horde `apikey` header for both chat and images. */ export const aihordeProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ id: "aihorde", diff --git a/open-sse/config/providers/registry/alibaba/index.ts b/open-sse/config/providers/registry/alibaba/index.ts index efe11a8a64..3f17f42720 100644 --- a/open-sse/config/providers/registry/alibaba/index.ts +++ b/open-sse/config/providers/registry/alibaba/index.ts @@ -1,6 +1,7 @@ import type { RegistryEntry, RegistryModel } from "../../shared.ts"; export const ALIBABA_MODEL_STUDIO_MODELS: RegistryModel[] = [ + { id: "qwen3.8-max", name: "Qwen3.8 Max" }, { id: "qwen3.7-max", name: "Qwen3.7 Max" }, { id: "qwen3.7-plus", name: "Qwen3.7 Plus" }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus" }, diff --git a/open-sse/config/providers/registry/antigravity/index.ts b/open-sse/config/providers/registry/antigravity/index.ts index 74addaf815..c3080b0103 100644 --- a/open-sse/config/providers/registry/antigravity/index.ts +++ b/open-sse/config/providers/registry/antigravity/index.ts @@ -25,4 +25,5 @@ export const antigravityProvider: RegistryEntry = { }, models: [...ANTIGRAVITY_PUBLIC_MODELS], passthroughModels: true, + liveCatalogAuthoritative: false, }; diff --git a/open-sse/config/providers/registry/clinepass/index.ts b/open-sse/config/providers/registry/clinepass/index.ts index c6af5abad8..8a3e319080 100644 --- a/open-sse/config/providers/registry/clinepass/index.ts +++ b/open-sse/config/providers/registry/clinepass/index.ts @@ -32,7 +32,7 @@ export const clinepassProvider: RegistryEntry = { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", }, - // Offline fallback copied from Cline CLI 3.0.46's generated catalog. Live + // Offline fallback copied from Cline CLI 3.0.53's generated catalog. Live // discovery replaces it with the authored recommended-models order. models: [ { @@ -111,6 +111,15 @@ export const clinepassProvider: RegistryEntry = { maxInputTokens: 1048576, maxOutputTokens: 131072, }, + { + id: "cline-pass/qwen3.8-max", + name: "Qwen3.8 Max", + toolCalling: true, + supportsReasoning: true, + contextLength: 1000000, + maxInputTokens: 1000000, + maxOutputTokens: 65536, + }, { id: "cline-pass/qwen3.7-max", name: "Qwen3.7 Max", diff --git a/open-sse/config/providers/registry/cloudflare-playground/index.ts b/open-sse/config/providers/registry/cloudflare-playground/index.ts new file mode 100644 index 0000000000..1c369d6979 --- /dev/null +++ b/open-sse/config/providers/registry/cloudflare-playground/index.ts @@ -0,0 +1,57 @@ +/** + * Cloudflare AI Playground — No Auth provider registry entry. + * + * Free, anonymous access to the Cloudflare AI Playground + * (https://playground.ai.cloudflare.com) — no account, no API key, no cookies. + * Chat runs over a PartySocket WebSocket speaking Cloudflare's `cf_agent` + * protocol; the only gate is a browser-grade TLS fingerprint on the WS upgrade, + * which the `cloudflare-playground` executor satisfies by driving a headless + * Chromium via Playwright (see executors/cloudflare-playground.ts). + * + * Model catalog captured from the playground's live `getModels` RPC + * (2026-08-15, 63 models total; the 20 chat/text-generation entries are listed + * here). Model IDs use the playground's `org/model` slug form — the executor + * prefixes them with `@cf/` when talking to the upstream. + */ +import type { RegistryEntry } from "../../shared.ts"; + +export const cloudflarePlaygroundProvider: RegistryEntry = { + id: "cloudflare-playground", + alias: "cfp", + format: "openai", + executor: "cloudflare-playground", + baseUrl: "https://playground.ai.cloudflare.com", + authType: "none", + authHeader: "none", + models: [ + // Frontier/open-weight flagships first. + { id: "zai-org/glm-5.2", name: "GLM 5.2 (Z.ai)", supportsReasoning: true }, + { id: "moonshotai/kimi-k2.7-code", name: "Kimi K2.7 Code (Moonshot)", supportsReasoning: true }, + { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6 (Moonshot)", supportsReasoning: true }, + { + id: "deepseek-ai/deepseek-v4-pro-0813", + name: "DeepSeek V4 Pro (DeepSeek)", + supportsReasoning: true, + }, + { id: "deepseek-ai/deepseek-v4-flash-0731", name: "DeepSeek V4 Flash (DeepSeek)" }, + { id: "zai-org/glm-4.7-flash", name: "GLM 4.7 Flash (Z.ai)", supportsReasoning: true }, + { id: "openai/gpt-oss-120b", name: "GPT-OSS 120B (OpenAI)" }, + { id: "openai/gpt-oss-20b", name: "GPT-OSS 20B (OpenAI)" }, + { id: "meta-llama/llama-3.3-70b-instruct-fp8-fast", name: "Llama 3.3 70B Instruct (Meta)" }, + { id: "meta/llama-3.1-8b-instruct-fp8", name: "Llama 3.1 8B Instruct (Meta)" }, + { id: "meta/llama-4-scout-17b-16e-instruct", name: "Llama 4 Scout 17B (Meta)" }, + { id: "nvidia/nemotron-3-120b-a12b", name: "Nemotron 3 120B (NVIDIA)" }, + { id: "qwen/qwen2.5-coder-32b-instruct", name: "Qwen2.5 Coder 32B (Qwen)" }, + { id: "qwen/qwen3-30b-a3b-fp8", name: "Qwen3 30B A3B (Qwen)" }, + { id: "qwen/qwq-32b", name: "QwQ 32B (Qwen)", supportsReasoning: true }, + { + id: "deepseek-ai/deepseek-r1-distill-qwen-32b", + name: "DeepSeek R1 Distill Qwen 32B", + supportsReasoning: true, + }, + { id: "google/gemma-4-26b-a4b-it", name: "Gemma 4 26B A4B (Google)" }, + { id: "mistralai/mistral-small-3.1-24b-instruct", name: "Mistral Small 3.1 24B" }, + { id: "ibm-granite/granite-4.0-h-micro", name: "Granite 4.0 H Micro (IBM)" }, + { id: "aisingapore/gemma-sea-lion-v4-27b-it", name: "Gemma SEA-LION V4 27B (AI Singapore)" }, + ], +}; diff --git a/open-sse/config/providers/registry/deepseek/index.ts b/open-sse/config/providers/registry/deepseek/index.ts index 933fb9bba1..e45f471bb8 100644 --- a/open-sse/config/providers/registry/deepseek/index.ts +++ b/open-sse/config/providers/registry/deepseek/index.ts @@ -24,7 +24,7 @@ export const deepseekProvider: RegistryEntry = { contextLength: 1_000_000, maxOutputTokens: 384_000, supportsReasoning: true, - supportedThinkingEfforts: ["none", "high", "max"], + supportedThinkingEfforts: ["none", "low", "high", "max"], toolCalling: true, }, { diff --git a/open-sse/config/providers/registry/mimocode/index.ts b/open-sse/config/providers/registry/mimocode/index.ts deleted file mode 100644 index 39023831c9..0000000000 --- a/open-sse/config/providers/registry/mimocode/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { RegistryEntry } from "../../shared.ts"; -import { CHAT_OPENAI_COMPAT_MODELS } from "../../shared.ts"; - -// Mimocode (Xiaomi MiMo free OpenAI-compatible gateway) — no-auth, custom executor. -// Re-added after the registry modularization (#3993) dropped it; restores #3837. -export const mimocodeProvider: RegistryEntry = { - id: "mimocode", - alias: "mcode", - format: "openai", - executor: "mimocode", - baseUrl: "https://api.xiaomimimo.com", - chatPath: "/api/free-ai/openai/chat", - authType: "none", - authHeader: "none", - models: CHAT_OPENAI_COMPAT_MODELS["mimocode"], -}; diff --git a/open-sse/config/providers/registry/opencode/go/index.ts b/open-sse/config/providers/registry/opencode/go/index.ts index 9ff9deda62..54e30e5738 100644 --- a/open-sse/config/providers/registry/opencode/go/index.ts +++ b/open-sse/config/providers/registry/opencode/go/index.ts @@ -131,27 +131,19 @@ export const opencode_goProvider: RegistryEntry = { { id: "grok-4.5-low", name: "Grok 4.5 (low effort)", supportsReasoning: true }, { id: "grok-4.5-medium", name: "Grok 4.5 (medium effort)", supportsReasoning: true }, { id: "grok-4.5-high", name: "Grok 4.5 (high effort)", supportsReasoning: true }, - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true }, - // OpencodeExecutor rewrites these aliases to the canonical upstream id and injects reasoning_effort. - { id: "deepseek-v4-pro-low", name: "DeepSeek V4 Pro (low effort)", supportsReasoning: true }, { - id: "deepseek-v4-pro-medium", - name: "DeepSeek V4 Pro (medium effort)", - supportsReasoning: true, - }, - { id: "deepseek-v4-pro-high", name: "DeepSeek V4 Pro (high effort)", supportsReasoning: true }, - { id: "deepseek-v4-pro-max", name: "DeepSeek V4 Pro (max effort)", supportsReasoning: true }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true }, - // #8353: DeepSeek V4 Flash effort tiers from the OpenCode Go registry. - { - id: "deepseek-v4-flash-high", - name: "DeepSeek V4 Flash (high effort)", + id: "deepseek-v4-pro", + name: "DeepSeek V4 Pro", supportsReasoning: true, + supportedThinkingEfforts: ["none", "low", "high", "max"], + targetFormat: "openai-responses", }, { - id: "deepseek-v4-flash-max", - name: "DeepSeek V4 Flash (max effort)", + id: "deepseek-v4-flash", + name: "DeepSeek V4 Flash", supportsReasoning: true, + supportedThinkingEfforts: ["none", "low", "high", "max"], + targetFormat: "openai-responses", }, ], }; diff --git a/open-sse/config/providers/registry/qwen-cloud-token-plan/index.ts b/open-sse/config/providers/registry/qwen-cloud-token-plan/index.ts index 310f91ad86..591332e983 100644 --- a/open-sse/config/providers/registry/qwen-cloud-token-plan/index.ts +++ b/open-sse/config/providers/registry/qwen-cloud-token-plan/index.ts @@ -11,13 +11,13 @@ export const qwen_cloud_token_planProvider: RegistryEntry = { authHeader: "bearer", models: [ { - id: "qwen3.8-max-preview", - name: "Qwen3.8 Max Preview", + id: "qwen3.8-max", + name: "Qwen3.8 Max", supportsReasoning: true, supportsVision: true, toolCalling: true, contextLength: 1_000_000, - maxOutputTokens: 65_536, + maxOutputTokens: 131_072, }, { id: "qwen3.7-max", @@ -25,7 +25,7 @@ export const qwen_cloud_token_planProvider: RegistryEntry = { supportsReasoning: true, toolCalling: true, contextLength: 1_000_000, - maxOutputTokens: 65_536, + maxOutputTokens: 131_072, }, { id: "qwen3.7-plus", @@ -34,7 +34,7 @@ export const qwen_cloud_token_planProvider: RegistryEntry = { supportsVision: true, toolCalling: true, contextLength: 1_000_000, - maxOutputTokens: 65_536, + maxOutputTokens: 131_072, }, { id: "qwen3.6-flash", @@ -43,7 +43,7 @@ export const qwen_cloud_token_planProvider: RegistryEntry = { supportsVision: true, toolCalling: true, contextLength: 1_000_000, - maxOutputTokens: 32_768, + maxOutputTokens: 65_536, }, { id: "glm-5.2", @@ -51,15 +51,23 @@ export const qwen_cloud_token_planProvider: RegistryEntry = { supportsReasoning: true, toolCalling: true, contextLength: 1_000_000, - maxOutputTokens: 16_384, + maxOutputTokens: 131_072, }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true, toolCalling: true, - contextLength: 163_840, - maxOutputTokens: 32_768, + contextLength: 1_000_000, + maxOutputTokens: 393_216, + }, + { + id: "deepseek-v4-flash-0731", + name: "DeepSeek V4 Flash", + supportsReasoning: true, + toolCalling: true, + contextLength: 1_000_000, + maxOutputTokens: 393_216, }, ], }; diff --git a/open-sse/config/providers/registry/qwen-cloud/index.ts b/open-sse/config/providers/registry/qwen-cloud/index.ts index af13fc7f41..a72aa3bd4b 100644 --- a/open-sse/config/providers/registry/qwen-cloud/index.ts +++ b/open-sse/config/providers/registry/qwen-cloud/index.ts @@ -1,6 +1,7 @@ import type { RegistryEntry, RegistryModel } from "../../shared.ts"; export const QWEN_CLOUD_TEXT_MODELS: RegistryModel[] = [ + { id: "qwen3.8-max", name: "Qwen3.8 Max" }, { id: "qwen3.7-max-2026-06-08", name: "Qwen3.7 Max (2026-06-08)" }, { id: "qwen3.7-plus", name: "Qwen3.7 Plus" }, { id: "qwen3.6-plus", name: "Qwen3.6 Plus" }, diff --git a/open-sse/config/providers/registry/qwen/web/index.ts b/open-sse/config/providers/registry/qwen/web/index.ts index 8bc7b47ed1..531c4bf1e1 100644 --- a/open-sse/config/providers/registry/qwen/web/index.ts +++ b/open-sse/config/providers/registry/qwen/web/index.ts @@ -17,13 +17,13 @@ export const qwen_webProvider: RegistryEntry = { // MODEL_ALIASES map for backward compatibility. models: [ { - id: "qwen3.8-max-preview", - name: "Qwen3.8 Max Preview", + id: "qwen3.8-max", + name: "Qwen3.8 Max", toolCalling: false, supportsReasoning: true, supportsVision: true, contextLength: 1_000_000, - maxOutputTokens: 65_536, + maxOutputTokens: 131_072, }, { id: "qwen3.7-max", diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index 5696ecf08f..88fff0fef7 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -279,8 +279,8 @@ export const GPT_5_6_CODEX_CAPABILITIES = { supportsReasoning: true, supportsVision: true, supportsXHighEffort: true, - contextLength: 1050000, - maxInputTokens: 922000, + contextLength: 272000, + maxInputTokens: 272000, maxOutputTokens: 128000, } as const; @@ -663,12 +663,6 @@ export const CHAT_OPENAI_COMPAT_MODELS: Record = { "mistralai/Mistral-7B-Instruct-v0.3", "Qwen/Qwen2.5-72B-Instruct", ]), - // Restored after the registry modularization (#3993) dropped the mimocode key - // referenced by the mimocode provider plugin. Source of truth: pre-#3993 - // providerRegistry.ts (commit 1ed01dd90^). - mimocode: [ - { id: "mimo-auto", name: "MiMo Auto", contextLength: 1000000, maxOutputTokens: 128000 }, - ], }; export function mapStainlessOs() { diff --git a/open-sse/config/rerankRegistry.ts b/open-sse/config/rerankRegistry.ts index a2241b8a49..f1647f9756 100644 --- a/open-sse/config/rerankRegistry.ts +++ b/open-sse/config/rerankRegistry.ts @@ -71,8 +71,10 @@ export const RERANK_PROVIDERS = { authType: "apikey", authHeader: "bearer", models: [ + { id: "jina-reranker-v3.5", name: "Jina Reranker v3.5" }, { id: "jina-reranker-v3", name: "Jina Reranker v3" }, { id: "jina-reranker-m0", name: "Jina Reranker m0" }, + { id: "jina-reranker-v2-base-multilingual", name: "Jina Reranker v2 Base Multilingual" }, ], }, diff --git a/open-sse/config/searchRegistry.ts b/open-sse/config/searchRegistry.ts index 8baf51deff..ce20777cb0 100644 --- a/open-sse/config/searchRegistry.ts +++ b/open-sse/config/searchRegistry.ts @@ -243,6 +243,24 @@ export const SEARCH_PROVIDERS: Record = { cacheTTLMs: 5 * 60 * 1000, }, + // Jina Search (s.jina.ai). No extra dashboard card — credentials reuse + // jina-ai / jina-reader / JINA_AI_API_KEY via SEARCH_CREDENTIAL_FALLBACKS. + "jina-search": { + id: "jina-search", + name: "Jina Search (s.jina.ai)", + baseUrl: "https://s.jina.ai", + method: "POST", + authType: "apikey", + authHeader: "bearer", + costPerQuery: 0.002, + freeMonthlyQuota: 1000, + searchTypes: ["web"], + defaultMaxResults: 5, + maxMaxResults: 50, + timeoutMs: 15_000, + cacheTTLMs: 5 * 60 * 1000, + }, + // Free, no-API-key DuckDuckGo lite scraping (free-claude-code port). Last-resort // only (fallbackOnly): never auto-selected over a configured provider; served by // the dedicated HTML path in open-sse/handlers/search.ts (not the generic JSON one). @@ -272,21 +290,45 @@ export const SEARCH_CREDENTIAL_FALLBACKS: Record = { "perplexity-search": "perplexity", "ollama-search": "ollama-cloud", "zai-search": "zai", + "jina-search": "jina-ai", }; /** - * Get search provider config by ID + * Request-only aliases for POST /v1/search. + * + * Do not apply these in getSearchProvider(). jina-ai is the Foundation + * embed/rerank/classify provider; remapping it here made the models + * catalog treat jina-ai as a search-only card (searchTypes → "web"). + */ +export const SEARCH_PROVIDER_ALIASES: Record = { + "jina-ai": "jina-search", + jina: "jina-search", +}; + +export function resolveSearchProviderId(providerId: string): string { + return SEARCH_PROVIDER_ALIASES[providerId] || providerId; +} + +/** + * Exact catalog lookup. Used by model listing / static catalogs. + * Request routing should use resolveSearchProvider() so aliases work + * without colliding with the Foundation jina-ai provider id. */ export function getSearchProvider(providerId: string): SearchProviderConfig | null { return SEARCH_PROVIDERS[providerId] || null; } +/** Resolve a /v1/search provider id, including Foundation aliases. */ +export function resolveSearchProvider(providerId: string): SearchProviderConfig | null { + return SEARCH_PROVIDERS[resolveSearchProviderId(providerId)] || null; +} + export function supportsSearchType( providerOrId: SearchProviderConfig | string | null | undefined, searchType: string ): boolean { const provider = - typeof providerOrId === "string" ? getSearchProvider(providerOrId) : providerOrId || null; + typeof providerOrId === "string" ? resolveSearchProvider(providerOrId) : providerOrId || null; if (!provider) return false; return provider.searchTypes.includes(searchType); } @@ -316,7 +358,7 @@ export function selectProvider( searchType?: string ): SearchProviderConfig | null { if (explicitProvider) { - const provider = SEARCH_PROVIDERS[explicitProvider] || null; + const provider = resolveSearchProvider(explicitProvider); if (!provider) return null; if (searchType && !supportsSearchType(provider, searchType)) return null; return provider; diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index dda321c266..d239f6d7d1 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -442,9 +442,10 @@ function sanitizeAntigravityGeminiRequest( * `"assistant"`). Mirrors the trailing-strip pop-loop already used for Mistral * (#3396), Copilot (#5802), and the CC-bridge in `claudeCodeCompatible.ts`. * - * Scoped strictly to the Claude path by the caller (`isClaude` branch only) — native - * Gemini models via Antigravity must be unaffected, since Vertex-Claude is the only - * documented rejection surface. + * Wired in by the caller for both the Claude path (`isClaude`) and native Gemini + * models (`isGemini`, #10104) — newer Gemini endpoints reject a trailing `model` turn + * with the same "ending with a model turn" class of 400 that Claude hits via Vertex. + * Other model families routed through Antigravity are left untouched. * * Guard: never strip `contents` down to empty — an empty `contents` array is itself * an invalid request, so at least one entry (even a lone trailing "model" turn) is @@ -468,6 +469,20 @@ function stripTrailingAntigravityAssistantTurn( return request; } +/** + * Newer Antigravity Gemini chat families reject a request ending on a model turn. + * Keep this explicit rather than matching every model containing "gemini": image + * generation has a separate request contract, and the older 2.5 family is not part + * of the rejection evidence for #10104. + */ +function isAntigravityGeminiChatModel(upstreamModel: string): boolean { + const normalizedModel = upstreamModel.toLowerCase(); + if (/(?:^|-)image(?:-|$)/.test(normalizedModel)) { + return false; + } + return /^gemini-(?:3(?:\.\d+)?(?:-[a-z0-9-]+)?|pro-agent)$/.test(normalizedModel); +} + // Test-only export so the unit suite can exercise the strip logic directly. export const __test_stripTrailingAntigravityAssistantTurn = stripTrailingAntigravityAssistantTurn; @@ -521,6 +536,17 @@ export class AntigravityExecutor extends BaseExecutor { super("antigravity", PROVIDERS.antigravity); } + override shouldRetry(status: number, urlIndex: number): boolean { + return ( + (status === HTTP_STATUS.RATE_LIMITED || + status === HTTP_STATUS.NOT_FOUND || + status === HTTP_STATUS.BAD_GATEWAY || + status === HTTP_STATUS.SERVICE_UNAVAILABLE || + status === HTTP_STATUS.GATEWAY_TIMEOUT) && + urlIndex + 1 < this.getFallbackCount() + ); + } + buildUrl(model: string, _stream: boolean, urlIndex = 0): string { void model; const baseUrls = this.getBaseUrls(); @@ -673,6 +699,14 @@ export class AntigravityExecutor extends BaseExecutor { const upstreamModel = await cleanModelName(model, modelIdOverride); const isClaude = upstreamModel.toLowerCase().includes("claude"); + // #10104: newer Gemini endpoints reject a request ending on a `model` turn with + // HTTP 400 "Requests ending with a model turn are not supported" — the same + // rejection surface Claude hits via Vertex (see stripTrailingAntigravityAssistantTurn's + // doc comment above). Native Gemini models routed through Antigravity (`agy/gemini-*`, + // e.g. the Gemini 3.x Flash/Pro tiers from PR #8013's catalog) need the same guarded + // strip. Scoped to models whose id names Gemini so unrelated model families are + // untouched; the strip itself never empties `contents` (see the guard above). + const isGemini = isAntigravityGeminiChatModel(upstreamModel); const baseBody = bodyRecord; const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel) ? stripCloudCodeThinkingConfig(baseBody) @@ -736,11 +770,16 @@ export class AntigravityExecutor extends BaseExecutor { : normalizedRequest?.toolConfig, }; + // Note: sanitizeAntigravityGeminiRequest() applies a Claude-only field whitelist + // (dropping fields native Gemini requests may legitimately carry), so the Gemini + // branch only runs the trailing-turn strip — never the sanitize/whitelist step. const transformedRequest = isClaude ? stripTrailingAntigravityAssistantTurn( sanitizeAntigravityGeminiRequest(rawTransformedRequest) ) - : rawTransformedRequest; + : isGemini + ? stripTrailingAntigravityAssistantTurn(rawTransformedRequest) + : rawTransformedRequest; applyAntigravityGenerationDefaults(transformedRequest, upstreamModel); diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 8fe668b622..c91cb5cbb7 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -480,7 +480,8 @@ export class BaseExecutor { stream = true, clientHeaders?: Record | null, model?: string, - health?: Record + health?: Record, + body?: unknown ): Record { void clientHeaders; void model; @@ -799,7 +800,7 @@ export class BaseExecutor { activeCredentials ); const url = this.buildUrl(model, stream, urlIndex, requestCredentials); - const headers = this.buildHeaders(requestCredentials, stream, clientHeaders, model); + const headers = this.buildHeaders(requestCredentials, stream, clientHeaders, model, undefined, body); applyConfiguredUserAgent(headers, requestCredentials?.providerSpecificData); // Strip OpenAI SDK (X-Stainless-*) metadata + normalize SDK-derived User-Agent @@ -1180,7 +1181,12 @@ export class BaseExecutor { // rejected; selectBetaFlags still gates thinking/effort per #3415. "anthropic-beta": mergeClientAnthropicBeta( selectBetaFlags(tb, null, clientAnthropicBeta), - clientAnthropicBeta + clientAnthropicBeta, + undefined, + // Gate the client-negotiated context-1m beta on the RESOLVED target: + // combo/fallback can route a request negotiated for a [1m] sibling onto a + // model that does not qualify (e.g. Haiku), which Anthropic rejects (#10119). + model ), "anthropic-dangerous-direct-browser-access": "true", "x-app": "cli", diff --git a/open-sse/executors/base/reasoningEffort.ts b/open-sse/executors/base/reasoningEffort.ts index 8356e6e131..f3d138bb79 100644 --- a/open-sse/executors/base/reasoningEffort.ts +++ b/open-sse/executors/base/reasoningEffort.ts @@ -297,23 +297,17 @@ export function sanitizeReasoningEffortForProvider( return writeEffortValue(b, "max", c); } - // Native DeepSeek (api.deepseek.com) — V4 thinking mode uses the native - // {low, high, max} vocabulary on Flash and {high, max} on Pro. OmniRoute's - // internal top tier xhigh maps to DeepSeek's literal max. Pro's unsupported - // low/medium values still clamp to high; Flash's documented low tier passes - // through. This is the INVERSE of the OpenRouter-DeepSeek path, whose - // normalized API expects xhigh, not max (pi#4055). `none` is already the - // OpenAI no-thinking carrier and passes through unchanged. + // Native DeepSeek (api.deepseek.com) — V4 Pro and Flash use the native + // {low, high, max} vocabulary, while other model ids retain the {high, max} + // floor. OmniRoute's internal top tier xhigh maps to DeepSeek's literal max, + // while compatibility-only medium maps to high. `none` is already the OpenAI + // no-thinking carrier and passes through unchanged. if (provider === "deepseek") { - // Match the Flash family even when the sanitizer sees a suffixed or prefixed - // id — exact-match would silently clamp Flash `low → high` if a future route - // forwards the raw catalog id (`deepseek-v4-flash-low`) before resolution - // (#9485 review). - const isFlash = modelStr.toLowerCase().startsWith("deepseek-v4-flash"); + const isV4 = modelStr.toLowerCase().startsWith("deepseek-v4-"); const mapped = effortStr === "xhigh" ? "max" - : effortStr === "medium" || (effortStr === "low" && !isFlash) + : effortStr === "medium" || (effortStr === "low" && !isV4) ? "high" : null; if (mapped && mapped !== effortStr) { diff --git a/open-sse/executors/cloudflare-playground.ts b/open-sse/executors/cloudflare-playground.ts new file mode 100644 index 0000000000..ba309f1eed --- /dev/null +++ b/open-sse/executors/cloudflare-playground.ts @@ -0,0 +1,591 @@ +/** + * CloudflarePlaygroundExecutor — Cloudflare AI Playground (No Auth) provider + * + * Reverse-engineered access to the free, anonymous Cloudflare AI Playground + * (https://playground.ai.cloudflare.com). No account, no API key, no cookies: + * chat runs over a PartySocket WebSocket speaking Cloudflare's `cf_agent` RPC + * protocol, and the only gate is a browser-grade TLS fingerprint on the WS + * upgrade. This executor therefore drives a headless Chromium via Playwright, + * opens the WebSocket *inside the page context* (only a real browser TLS stack + * passes the upgrade), and translates the `cf_agent` frame stream into + * OpenAI-format chat completion chunks. + * + * Protocol (captured live 2026-08-15): + * - Transport: wss://playground.ai.cloudflare.com/agents/playground/?_pk= + * - Resume: {"type":"cf_agent_stream_resume_request"} + * - Config: {"type":"rpc","method":"setConfig","args":[{model,temperature,stream}]} + * - Chat: {"id":,"init":{"method":"POST","body":{messages,trigger}},"type":"cf_agent_use_chat_request"} + * - Stream: start → start-step → (reasoning-start/delta/end)* → text-start → + * text-delta* → finish-step → finish{messageMetadata.finishReason} → {done:true} + * - Errors: {"error":true,"body":"{message,details}","id":} — e.g. + * "3021: rate limiting: inference request per min rate reached" + * + * Notes: + * - The playground's system prompt is server-side (set via setConfig by the + * app itself); client `system` messages are dropped. Tool calls are not + * implemented (v1) — text-only chat. + * - Upstream rate limits arrive in-band as `error:true` frames. Non-streaming + * requests surface them as HTTP 429/502; streaming requests emit an SSE + * error chunk before `[DONE]` (the response status is already committed). + * A server-side chat timeout follows the same rule: streaming requests + * emit a `timeout_error` chunk before `[DONE]` instead of silently + * completing (#10494). + * - Set CLOUDFLARE_PLAYGROUND_CHROME_PATH to point at a full desktop Chrome + * binary when Playwright's bundled Chromium gets fingerprint-blocked. + */ +import { randomUUID } from "crypto"; +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; +import type { Browser, Page } from "playwright"; + +export const PLAYGROUND_URL = "https://playground.ai.cloudflare.com/"; +const PLAYGROUND_WS_BASE = "wss://playground.ai.cloudflare.com/agents/playground/"; +const PLAYGROUND_UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; +const BROWSER_ARGS = [ + "--disable-blink-features=AutomationControlled", + "--no-first-run", + "--no-default-browser-check", +]; +const MODEL_PREFIX = "@cf/"; +const DEFAULT_MODEL = "zai-org/glm-4.7-flash"; +const DEFAULT_TEMPERATURE = 0.7; +const NAV_TIMEOUT_MS = 45_000; +const CHAT_TIMEOUT_MS = 120_000; +const BLOCKED_MESSAGE = + "Cloudflare Playground blocked the headless browser (fingerprint check). Set CLOUDFLARE_PLAYGROUND_CHROME_PATH to a full desktop Chrome binary and retry."; + +// ── Frame parsing & translation (pure — unit-tested against live captures) ── + +export interface CfChatFrame { + id?: string; + type?: string; + error?: boolean; + done?: boolean; + body?: unknown; +} + +/** Parse a raw WS frame. Returns null for non-JSON / unrelated frames. */ +export function parseCfFrame(raw: string): CfChatFrame | null { + try { + const msg = JSON.parse(raw) as CfChatFrame; + if (msg && typeof msg === "object" && typeof msg.type === "string") return msg; + } catch { + /* non-JSON — ignore */ + } + return null; +} + +export interface CfStreamEvent { + type: "role" | "content" | "reasoning" | "finish"; + value?: string; +} + +/** + * Translates `cf_agent_use_chat_response` frames for one chat id into + * OpenAI-format stream events. Frames for other ids (RPC responses such as + * `setConfig` also carry `done:true`!) and non-chat frame types + * (`cf_agent_identity`, `cf_agent_state`, ...) are ignored. + */ +export class CfStreamParser { + readonly chatId: string; + done = false; + text = ""; + reasoningText = ""; + finishReason: string | null = null; + error: { status: number; message: string } | null = null; + private seenStart = false; + + constructor(chatId: string) { + this.chatId = chatId; + } + + /** Returns the SSE-relevant event, or null when the frame is ignorable. */ + push(raw: string): CfStreamEvent | null { + const msg = parseCfFrame(raw); + if (!msg || msg.type !== "cf_agent_use_chat_response" || msg.id !== this.chatId) return null; + + if (msg.error) { + this.error = classifyError(msg.body); + return null; + } + if (msg.done) { + this.done = true; + return null; + } + + let body: Record; + try { + body = + typeof msg.body === "string" + ? (JSON.parse(msg.body) as Record) + : (msg.body as Record); + } catch { + return null; + } + if (!body || typeof body.type !== "string") return null; + + switch (body.type) { + case "start": + if (this.seenStart) return null; + this.seenStart = true; + return { type: "role" }; + case "reasoning-delta": { + const delta = typeof body.delta === "string" ? body.delta : ""; + if (!delta) return null; + this.reasoningText += delta; + return { type: "reasoning", value: delta }; + } + case "text-delta": { + const delta = typeof body.delta === "string" ? body.delta : ""; + if (!delta) return null; + this.text += delta; + return { type: "content", value: delta }; + } + case "finish": { + const meta = (body.messageMetadata ?? {}) as Record; + const reason = typeof meta.finishReason === "string" ? meta.finishReason : "stop"; + this.finishReason = reason; + return { type: "finish", value: reason }; + } + default: + // reasoning-start/end, start-step, finish-step, text-start/end, heartbeat — ignored. + return null; + } + } +} + +/** Map an in-band upstream error frame to an HTTP-ish status + clean message. */ +function classifyError(body: unknown): { status: number; message: string } { + let detail = ""; + if (typeof body === "string") { + try { + const parsed = JSON.parse(body) as Record; + detail = String(parsed.details || parsed.message || ""); + } catch { + detail = body; + } + } else if (body && typeof body === "object") { + const parsed = body as Record; + detail = String(parsed.details || parsed.message || ""); + } + const status = /rate|limit|quota|throttl/i.test(detail) ? 429 : 502; + return { status, message: detail || "Cloudflare Playground upstream error" }; +} + +// ── Message conversion ─────────────────────────────────────────────────────── + +export interface CfChatMessage { + role: "user" | "assistant"; + parts: Array<{ type: "text"; text: string }>; + id: string; +} + +/** + * Convert OpenAI-format messages to the playground's chat body shape. + * `system` messages are dropped (the playground's persona is server-side) and + * tool/image parts are flattened to text — v1 is text-only chat. + */ +export function toCfMessages( + messages: Array<{ role?: string; content?: unknown }> +): CfChatMessage[] { + const out: CfChatMessage[] = []; + for (const message of messages ?? []) { + if (message.role !== "user" && message.role !== "assistant") continue; + let text = ""; + if (typeof message.content === "string") { + text = message.content; + } else if (Array.isArray(message.content)) { + text = message.content + .map((part) => + typeof part === "string" ? part : ((part as { text?: string })?.text ?? "") + ) + .filter(Boolean) + .join("\n"); + } + if (!text) continue; + out.push({ role: message.role, parts: [{ type: "text", text }], id: `m${out.length + 1}` }); + } + return out; +} + +// ── Transport ──────────────────────────────────────────────────────────────── + +export interface CfTransportConfig { + model: string; + messages: CfChatMessage[]; + temperature: number; + signal?: AbortSignal | null; +} + +export interface CfTransport { + start( + config: CfTransportConfig + ): Promise<{ ok: true } | { ok: false; status: number; message: string }>; + frames(): AsyncGenerator; + close(): Promise; +} + +/** Open the anonymous playground session inside the browser page context. */ +function openPlaygroundSession(args: { + chatId: string; + model: string; + messages: CfChatMessage[]; + temperature: number; + wsBase: string; +}): void { + const { chatId, model, messages, temperature, wsBase } = args; + const pk = crypto.randomUUID(); + const room = "playground-" + crypto.randomUUID().replace(/-/g, "").slice(0, 25); + const socket = new WebSocket(wsBase + room + "?_pk=" + pk); + const push = (raw: string) => { + try { + (window as unknown as { __cfpPush: (raw: string) => void }).__cfpPush(raw); + } catch { + /* page torn down */ + } + }; + socket.onopen = () => { + socket.send(JSON.stringify({ type: "cf_agent_stream_resume_request" })); + socket.send( + JSON.stringify({ + type: "rpc", + id: "cfp-config", + method: "setConfig", + args: [{ model, temperature, stream: true }], + }) + ); + socket.send( + JSON.stringify({ + id: chatId, + init: { method: "POST", body: JSON.stringify({ messages, trigger: "submit-message" }) }, + type: "cf_agent_use_chat_request", + }) + ); + }; + socket.onmessage = (event: MessageEvent) => push(String(event.data)); + socket.onerror = () => + push( + JSON.stringify({ + id: chatId, + type: "cf_agent_use_chat_response", + error: true, + body: JSON.stringify({ + message: "Playground WebSocket error", + details: "ws transport failed", + }), + }) + ); +} + +export class PlaywrightCfTransport implements CfTransport { + private browser: Browser | null = null; + private page: Page | null = null; + private pending: string[] = []; + private waiters: Array<(frame: string | null) => void> = []; + private closed = false; + private abortSignal: AbortSignal | null = null; + private abortListener: (() => void) | null = null; + + constructor( + private chatId: string, + private chromeExecutablePath?: string + ) {} + + async start( + config: CfTransportConfig + ): Promise<{ ok: true } | { ok: false; status: number; message: string }> { + try { + const playwright = await importPlaywright(); + const executablePath = + this.chromeExecutablePath ?? process.env.CLOUDFLARE_PLAYGROUND_CHROME_PATH; + this.browser = await playwright.chromium.launch({ + ...(executablePath ? { executablePath } : {}), + headless: true, + args: BROWSER_ARGS, + }); + const context = await this.browser.newContext({ userAgent: PLAYGROUND_UA }); + const page = await context.newPage(); + this.page = page; + await page.goto(PLAYGROUND_URL, { waitUntil: "domcontentloaded", timeout: NAV_TIMEOUT_MS }); + const title = await page.title().catch(() => ""); + if (title.includes("Attention Required")) { + // #10494: this branch used to return without closing the browser it + // just launched, leaking a Chromium process for every blocked + // request. Close it on every non-success start path, same as the + // catch block below. + await this.close().catch(() => {}); + return { ok: false, status: 502, message: BLOCKED_MESSAGE }; + } + await page.exposeFunction("__cfpPush", (raw: string) => { + this.push(raw); + }); + // Bundlers (esbuild/webpack keepNames) inject a `__name` helper call into + // serialized function bodies; define it in the page context so + // page.evaluate(openPlaygroundSession) doesn't throw ReferenceError. + await page.evaluate(() => { + (window as unknown as { __name?: unknown }).__name = (fn: unknown) => fn; + }); + await page.evaluate(openPlaygroundSession, { + ...config, + chatId: this.chatId, + wsBase: PLAYGROUND_WS_BASE, + }); + if (config.signal) { + this.abortSignal = config.signal; + this.abortListener = () => { + void this.close(); + }; + config.signal.addEventListener("abort", this.abortListener, { once: true }); + } + return { ok: true }; + } catch (error) { + await this.close().catch(() => {}); + return { + ok: false, + status: 502, + message: `Cloudflare Playground browser session failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + push(raw: string): void { + const waiter = this.waiters.shift(); + if (waiter) waiter(raw); + else this.pending.push(raw); + } + + async *frames(): AsyncGenerator { + while (this.pending.length > 0 || !this.closed) { + if (this.pending.length > 0) { + yield this.pending.shift()!; + continue; + } + const frame = await new Promise((resolve) => this.waiters.push(resolve)); + if (frame === null) return; + yield frame; + } + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + if (this.abortSignal && this.abortListener) { + this.abortSignal.removeEventListener("abort", this.abortListener); + } + this.abortSignal = null; + this.abortListener = null; + for (const waiter of this.waiters.splice(0)) waiter(null); + const browser = this.browser; + this.browser = null; + if (browser) await browser.close().catch(() => {}); + } +} + +async function importPlaywright(): Promise { + try { + return await import("playwright"); + } catch { + throw new Error( + "Playwright is not available. Install it (npm i playwright && npx playwright install chromium) or set CLOUDFLARE_PLAYGROUND_CHROME_PATH to a Chrome binary." + ); + } +} + +// ── Executor ───────────────────────────────────────────────────────────────── + +function sseChunk( + cid: string, + created: number, + model: string, + payload: { delta?: Record; finish_reason?: string | null; error?: unknown } +): string { + const base = { id: cid, object: "chat.completion.chunk", created, model }; + if (payload.error) { + return `data: ${JSON.stringify({ ...base, error: payload.error })}\n\n`; + } + return `data: ${JSON.stringify({ + ...base, + choices: [ + { index: 0, delta: payload.delta ?? {}, finish_reason: payload.finish_reason ?? null }, + ], + })}\n\n`; +} + +export class CloudflarePlaygroundExecutor extends BaseExecutor { + constructor( + private transportFactory: (chatId: string) => CfTransport = (chatId) => + new PlaywrightCfTransport(chatId), + // Injectable so tests can force the timeout branch without waiting + // CHAT_TIMEOUT_MS (120s) for a real timer to fire. + private chatTimeoutMs: number = CHAT_TIMEOUT_MS + ) { + super("cloudflare-playground", { id: "cloudflare-playground", baseUrl: PLAYGROUND_URL }); + } + + async execute(input: ExecuteInput) { + const { body, signal, stream: wantStream } = input; + const bodyObj = (body || {}) as Record; + const rawModel = (bodyObj.model as string) || DEFAULT_MODEL; + const model = rawModel.startsWith(MODEL_PREFIX) ? rawModel : MODEL_PREFIX + rawModel; + const temperature = + typeof bodyObj.temperature === "number" ? bodyObj.temperature : DEFAULT_TEMPERATURE; + const chatId = `chatcmpl-cfp-${randomUUID().slice(0, 12)}`; + const created = Math.floor(Date.now() / 1000); + + const transport = this.transportFactory(chatId); + const started = await transport.start({ + model, + messages: toCfMessages( + (bodyObj.messages as Array<{ role?: string; content?: unknown }>) || [] + ), + temperature, + signal, + }); + if (started.ok !== true) { + return makeErrorResult(started.status, started.message, body, PLAYGROUND_URL); + } + + const timedOut = { current: false }; + const timer = setTimeout(() => { + timedOut.current = true; + void transport.close(); + }, this.chatTimeoutMs); + + try { + if (!wantStream) { + const parser = new CfStreamParser(chatId); + for await (const raw of transport.frames()) { + parser.push(raw); + if (parser.error || parser.done) break; + } + if (parser.error) { + return makeErrorResult(parser.error.status, parser.error.message, body, PLAYGROUND_URL); + } + if (timedOut.current && !parser.text) { + return makeErrorResult(504, "Cloudflare Playground timed out", body, PLAYGROUND_URL); + } + const text = parser.text; + const messagePayload: Record = { role: "assistant", content: text }; + if (parser.reasoningText) { + messagePayload.reasoning_content = parser.reasoningText; + } + return { + response: new Response( + JSON.stringify({ + id: chatId, + object: "chat.completion", + created, + model: rawModel, + choices: [ + { + index: 0, + message: messagePayload, + finish_reason: parser.finishReason ?? "stop", + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: Math.ceil((text.length + parser.reasoningText.length) / 4), + total_tokens: 0, + }, + }), + { headers: { "Content-Type": "application/json" } } + ), + url: PLAYGROUND_URL, + headers: {}, + transformedBody: body, + }; + } + + // Streaming: translate cf_agent frames → OpenAI SSE chunks. + const encoder = new TextEncoder(); + const responseStream = new ReadableStream({ + async start(controller) { + const parser = new CfStreamParser(chatId); + let roleSent = false; + const enqueue = (payload: { + delta?: Record; + finish_reason?: string | null; + error?: unknown; + }) => { + controller.enqueue(encoder.encode(sseChunk(chatId, created, rawModel, payload))); + }; + try { + for await (const raw of transport.frames()) { + if (signal?.aborted) break; + const event = parser.push(raw); + if (event) { + if (event.type === "role" && !roleSent) { + enqueue({ delta: { role: "assistant" }, finish_reason: null }); + roleSent = true; + } else if (event.type === "reasoning") { + enqueue({ delta: { reasoning_content: event.value }, finish_reason: null }); + } else if (event.type === "content") { + enqueue({ delta: { content: event.value }, finish_reason: null }); + } else if (event.type === "finish") { + enqueue({ delta: {}, finish_reason: event.value ?? "stop" }); + } + } + if (parser.error) { + enqueue({ + error: { + message: parser.error.message, + type: "upstream_error", + code: `HTTP_${parser.error.status}`, + }, + }); + break; + } + if (parser.done || timedOut.current) break; + } + } catch (error) { + if (!signal?.aborted) controller.error(error); + } finally { + clearTimeout(timer); + await transport.close().catch(() => {}); + // #10494: a timeout used to fall straight through to a bare + // [DONE], so a client receiving an empty or partial stream saw + // an ordinary successful completion. Emit an explicit error + // chunk first (same shape as the parser.error branch above) so + // the client can distinguish a timed-out/partial answer from a + // real completion. + if (timedOut.current) { + try { + enqueue({ + error: { + message: "Cloudflare Playground timed out", + type: "timeout_error", + code: "HTTP_504", + }, + }); + } catch { + /* stream already torn down */ + } + } + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + } + }, + }); + + return { + response: new Response(responseStream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: PLAYGROUND_URL, + headers: {}, + transformedBody: body, + }; + } finally { + if (!wantStream) { + clearTimeout(timer); + await transport.close().catch(() => {}); + } + } + } +} diff --git a/open-sse/executors/default.ts b/open-sse/executors/default.ts index d46207aae9..056772040a 100644 --- a/open-sse/executors/default.ts +++ b/open-sse/executors/default.ts @@ -388,7 +388,12 @@ export class DefaultExecutor extends BaseExecutor { } } - buildHeaders(credentials, stream = true, clientHeaders?: Record | null) { + buildHeaders( + credentials, + stream = true, + clientHeaders?: Record | null, + model?: string | null + ) { const { headers, effectiveKey } = this.buildHeadersPreamble(credentials, stream); switch (this.provider) { @@ -594,7 +599,15 @@ export class DefaultExecutor extends BaseExecutor { const clientBeta = clientHeaders["anthropic-beta"] ?? clientHeaders["Anthropic-Beta"] ?? null; const betaKey = Object.keys(headers).find((key) => key.toLowerCase() === "anthropic-beta"); if (betaKey && clientBeta) { - headers[betaKey] = mergeClientAnthropicBeta(headers[betaKey], clientBeta); + headers[betaKey] = mergeClientAnthropicBeta( + headers[betaKey], + clientBeta, + undefined, + // Gate the client-negotiated context-1m beta on the RESOLVED target model: + // combo/fallback can route a request negotiated for a [1m] sibling onto a + // model that does not qualify (e.g. Haiku), which Anthropic rejects (#10119). + model + ); } } diff --git a/open-sse/executors/gemini-web.ts b/open-sse/executors/gemini-web.ts index 8810b43cc3..3ae6df79cd 100644 --- a/open-sse/executors/gemini-web.ts +++ b/open-sse/executors/gemini-web.ts @@ -260,6 +260,70 @@ export function parseStreamResponse(raw: string): string { return lastText; } +/** + * Extract generated-image URLs from a Gemini StreamGenerate response (#10466). + * + * When the web UI generates images (Nano Banana), the model's answer frames + * carry the assets in the candidate's extension block, NOT in the text: + * + * inner[4][0][12][7][0] → array of generated-image entries + * entry[0][3][3] → the image URL — either a plain string or a + * list of strings (take the first http(s) one) + * + * This path is corroborated by the two maintained reverse-engineered clients + * (gpt4free's Gemini provider and HanaokaYuzu/Gemini-API's _parse_candidate). + * Deliberately NOT collected: `inner[4][0][12][1]` — those are web-search + * result thumbnails, not generated content; mixing them in would serve + * scraped images as "generated" (#10466 acceptance criteria). + * + * Frames are cumulative snapshots, so later frames repeat earlier images; + * we dedupe while preserving first-seen order. A `=s2048` size suffix is + * appended (gpt4free's proven heuristic) so callers get full-resolution + * assets instead of UI thumbnails. + */ +export function parseStreamResponseImages(raw: string): string[] { + const urls: string[] = []; + const seen = new Set(); + const lines = raw.split("\n"); + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line === ")]}'" || /^\d+$/.test(line)) continue; + if (!line.includes("wrb.fr")) continue; + try { + const arr = JSON.parse(line); + if (!Array.isArray(arr) || !Array.isArray(arr[0]) || arr[0][0] !== "wrb.fr") continue; + const payload = arr[0]?.[2]; + if (typeof payload !== "string") continue; + const inner = JSON.parse(payload); + const imageEntries = inner?.[4]?.[0]?.[12]?.[7]?.[0]; + if (!Array.isArray(imageEntries)) continue; + for (const entry of imageEntries) { + const urlField = entry?.[0]?.[3]?.[3]; + let url = ""; + if (typeof urlField === "string") { + url = urlField; + } else if (Array.isArray(urlField)) { + const firstHttp = urlField.find( + (u: unknown) => typeof u === "string" && /^https?:\/\//.test(u) + ); + url = typeof firstHttp === "string" ? firstHttp : ""; + } + if (!url || !/^https?:\/\//.test(url)) continue; + // Upgrade to full resolution unless a size directive is already present + // (googleusercontent size syntax: trailing `=s2048`, `=w1024-h512`, ...). + if (!/=[swh]\d+/.test(url)) url += "=s2048"; + if (seen.has(url)) continue; + seen.add(url); + urls.push(url); + } + } catch { + // Skip unparseable lines + } + } + return urls; +} + function readCredentialString(value: unknown): string { if (typeof value !== "string") return ""; const trimmed = value.trim(); @@ -365,9 +429,7 @@ export class GeminiWebExecutor extends BaseExecutor { _signal?: AbortSignal ): Promise { try { - const cookie = resolveGeminiWebCookie( - credentials as unknown as ExecuteInput["credentials"] - ); + const cookie = resolveGeminiWebCookie(credentials as unknown as ExecuteInput["credentials"]); if (!cookie) return false; const pairs = parseCookies(cookie); return pairs.some((p) => p.value.length > 0); @@ -506,20 +568,52 @@ export class GeminiWebExecutor extends BaseExecutor { const page = await context.newPage(); + // #10466: image mode — the /v1/images/generations handler sets + // x_gemini_web_image_mode. Generated images arrive in the candidate's + // extension block ([12][7][0]) of the StreamGenerate frames, sometimes + // only in a LATER frame of the stream (or a follow-up StreamGenerate + // call), so image mode captures every StreamGenerate response, merges + // image URLs across frames, and resolves as soon as one is found. + // Chat mode keeps the original first-response-only behavior. + const imageMode = (body as Record)?.x_gemini_web_image_mode === true; + // Capture first StreamGenerate response let responseText = ""; + const responseImages: string[] = []; let captured = false; const responsePromise = new Promise((resolve) => { page.on("response", async (resp: any) => { - if (captured || !resp.url().includes("StreamGenerate")) return; - captured = true; - try { - const raw = await resp.text(); - responseText = parseStreamResponse(raw); - } catch { - /* ignore */ + if (!resp.url().includes("StreamGenerate")) return; + if (!imageMode && captured) return; + if (imageMode) { + // Image mode: merge text + image URLs across every frame and + // resolve as soon as an image appears (images can land in a + // later frame than the text). + try { + const raw = await resp.text(); + const text = parseStreamResponse(raw); + if (text) responseText = text; + for (const url of parseStreamResponseImages(raw)) { + if (!responseImages.includes(url)) responseImages.push(url); + } + } catch { + /* ignore unreadable frames */ + } + if (responseImages.length > 0) resolve(); + } else { + // Chat mode: byte-for-byte the original first-response capture — + // resolve even if reading the body throws, so the flow falls + // through to the "No response from Gemini" 502 instead of + // burning the full wait window. + captured = true; + try { + const raw = await resp.text(); + responseText = parseStreamResponse(raw); + } catch { + /* ignore */ + } + resolve(); } - resolve(); }); }); @@ -538,12 +632,36 @@ export class GeminiWebExecutor extends BaseExecutor { await page.waitForTimeout(300); await page.keyboard.press("Enter"); - // Wait for response or timeout - await Promise.race([responsePromise, page.waitForTimeout(30000)]); + // Wait for response or timeout. Image generation (Nano Banana) is + // noticeably slower than text — the UI renders the asset only after + // the full generation completes — so image mode gets a wider window. + await Promise.race([responsePromise, page.waitForTimeout(imageMode ? 90000 : 30000)]); if (signal?.aborted) { throw signal.reason instanceof Error ? signal.reason : new Error("Request aborted"); } + // #10466 image mode: return the captured image URLs to the image + // handler via a custom field (same precedent as chatgpt-web's + // x_image_resolution_failed). An image-only answer can carry little or + // no text, so the empty-text 502 below must not fire when images + // were captured. + if (imageMode) { + await this.persistRotatedCookies(context, cookie, credentials, onCredentialsRefreshed, log); + const modelId = model || "gemini-2.5-pro"; + return { + response: new Response( + JSON.stringify({ + ...formatChatCompletion(responseText, modelId), + x_gemini_web_image_urls: responseImages, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ), + url: GEMINI_URL, + headers: {}, + transformedBody: body, + }; + } + if (!responseText) { return { response: new Response(JSON.stringify({ error: "No response from Gemini" }), { diff --git a/open-sse/executors/gitlab.ts b/open-sse/executors/gitlab.ts index 594dfa7e47..fa0b22c5c1 100644 --- a/open-sse/executors/gitlab.ts +++ b/open-sse/executors/gitlab.ts @@ -583,10 +583,20 @@ export class GitlabExecutor extends BaseExecutor { } if (response.status === 401) { + if (input.log) { + input.log.warn( + "GITLAB-DUO", + "direct_access exchange rejected (401); falling back to public completions endpoint" + ); + } return { - target: null, + target: { + mode: "monolith", + url: endpoints.publicCompletionsUrl, + headers: buildMonolithHeaders(credentials.accessToken || null), + }, credentials, - errorResponse: toOpenAIError(401, "GitLab Duo direct access token request was rejected"), + errorResponse: null, }; } diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index faa8c667f5..3fc2bdf9b3 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -73,10 +73,10 @@ import { MoonshotExecutor } from "./moonshot.ts"; import { TheOldLlmExecutor } from "./theoldllm.ts"; import { ChipotleExecutor } from "./chipotle.ts"; import { LMArenaExecutor } from "./lmarena.ts"; -import { MimocodeExecutor } from "./mimocode.ts"; import { GrokCliExecutor } from "./grok-cli.ts"; import { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; import { ZenmuxFreeExecutor } from "./zenmux-free.ts"; +import { CloudflarePlaygroundExecutor } from "./cloudflare-playground.ts"; import { TinyCmsExecutor } from "./tinycms.ts"; import { HyperAgentExecutor } from "./hyperagent.ts"; import { XaiExecutor } from "./xai.ts"; @@ -211,13 +211,13 @@ const executors = { pepper: new ChipotleExecutor(), // Alias lmarena: new LMArenaExecutor(), lma: new LMArenaExecutor(), // Alias - mimocode: new MimocodeExecutor(), - mcode: new MimocodeExecutor(), // Alias "grok-cli": new GrokCliExecutor(), gc: new GrokCliExecutor(), // Alias "codebuddy-cn": new CodeBuddyCnExecutor(), cbcn: new CodeBuddyCnExecutor(), // Alias for codebuddy-cn "zenmux-free": new ZenmuxFreeExecutor(), + "cloudflare-playground": new CloudflarePlaygroundExecutor(), + cfp: new CloudflarePlaygroundExecutor(), // Alias for cloudflare-playground "tinycms-web": new TinyCmsExecutor(), tcw: new TinyCmsExecutor(), // Alias hyperagent: new HyperAgentExecutor(), @@ -344,10 +344,10 @@ export { HailuoWebExecutor } from "./hailuo-web.ts"; export { TheOldLlmExecutor } from "./theoldllm.ts"; export { ChipotleExecutor } from "./chipotle.ts"; export { LMArenaExecutor } from "./lmarena.ts"; -export { MimocodeExecutor } from "./mimocode.ts"; export { GrokCliExecutor } from "./grok-cli.ts"; export { CodeBuddyCnExecutor } from "./codebuddy-cn.ts"; export { ZenmuxFreeExecutor } from "./zenmux-free.ts"; +export { CloudflarePlaygroundExecutor } from "./cloudflare-playground.ts"; export { TinyCmsExecutor } from "./tinycms.ts"; export { HyperAgentExecutor } from "./hyperagent.ts"; export { XaiExecutor } from "./xai.ts"; diff --git a/open-sse/executors/mimocode.ts b/open-sse/executors/mimocode.ts deleted file mode 100644 index 9ee27e0afc..0000000000 --- a/open-sse/executors/mimocode.ts +++ /dev/null @@ -1,711 +0,0 @@ -/** - * MiMoCode Executor — Free-tier Xiaomi MiMo models via bootstrap JWT auth. - * - * Implements the auth flow from the official MiMo-Code repository: - * https://github.com/XiaomiMiMo/MiMo-Code/blob/main/packages/opencode/src/plugin/mimo-free.ts - * - * 1. Generate device fingerprint from hostname + OS + arch + CPU + username - * 2. POST /api/free-ai/bootstrap with fingerprint → JWT - * 3. Use JWT as Bearer token for chat requests - * 4. Custom endpoint: /api/free-ai/openai/chat (not /v1/chat/completions) - * 5. Custom header: X-Mimo-Source: mimocode-cli-free - * - * Only the "mimo-auto" model is supported (1M context, 128K output). - * Supports multiple accounts: N fingerprints → N JWTs → round-robin with cooldown. - * On 429 — or a 400 carrying MiMoCode's rate-limit text — account enters cooldown - * (exponential backoff) and the next account is tried. On 401/403, JWT is - * re-bootstrapped. Any other 400 is a genuinely malformed request (#2101): it fails - * fast on the current account instead of being retried identically on every - * account, which would waste N round-trips, cooldown every account, and hide the - * real upstream diagnostic behind a generic "all accounts exhausted" error (#4976). - */ - -import * as crypto from "node:crypto"; -import * as os from "node:os"; -import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; -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 MIMO_SOURCE = "mimocode-cli-free"; - -/** - * Anti-abuse gate marker required by the Xiaomi free endpoint. - * - * `/api/free-ai/openai/chat` returns `403 "Illegal access"` unless the request body - * contains a recognized MiMoCode prompt signature as a substring inside a `system`-role - * message (verified empirically — headers, fingerprint, and JWT are not what is checked). - * This is the canonical MiMoCode agent opener the official CLI sends, and it is on the - * upstream allowlist. We inject it as a leading system message so user requests pass the - * gate. The string MUST stay byte-for-byte identical — the check is case-sensitive and - * truncations are rejected. - */ -export const MIMO_SYSTEM_MARKER = - "You are MiMoCode, an interactive CLI tool that helps users with software engineering tasks."; - -/** - * Ensure the outgoing body carries the MiMoCode anti-abuse marker in a system message. - * Idempotent: if any system message already contains the marker, the body is returned - * unchanged. Bodies without a `messages` array are left untouched. - */ -function injectSystemMarker(body: Record): Record { - const messages = body.messages; - if (!Array.isArray(messages)) return body; - - const hasMarker = messages.some( - (m) => - m != null && - typeof m === "object" && - (m as { role?: unknown }).role === "system" && - typeof (m as { content?: unknown }).content === "string" && - (m as { content: string }).content.includes(MIMO_SYSTEM_MARKER) - ); - if (hasMarker) return body; - - return { ...body, messages: [{ role: "system", content: MIMO_SYSTEM_MARKER }, ...messages] }; -} - -const USER_AGENTS = [ - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", -]; - -// ── Account State ────────────────────────────────────────────────────────── - -/** Per-account proxy configuration, passed through providerSpecificData.accountProxies. */ -export type AccountProxyConfig = SharedAccountProxyConfig; - -interface AccountState extends RotatableAccount { - fingerprint: string; - jwt: string; - expiresAt: 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 — - * syncAccountsFromCredentials() writes it on every account on every sync. - */ - proxy: AccountProxyConfig["proxy"]; -} - -function parseJwtExp(jwt: string): number { - try { - const parts = jwt.split("."); - if (parts.length < 2) return Date.now() + 50 * 60 * 1000; - const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); - return (payload.exp ?? Math.floor(Date.now() / 1000) + 3000) * 1000; - } catch { - return Date.now() + 50 * 60 * 1000; - } -} - -function isAccountReady(account: AccountState): boolean { - if (account.cooldownUntil > Date.now()) return false; - if (account.jwt && account.expiresAt - Date.now() > JWT_REFRESH_BUFFER_MS) return true; - return false; -} - -// ── Fingerprint Generation ───────────────────────────────────────────────── - -function getCpuModel(): string { - try { - const cpus = os.cpus(); - if (cpus.length > 0 && cpus[0].model) return cpus[0].model.trim(); - } catch { - /* ignore */ - } - return "unknown-cpu"; -} - -export function generateFingerprint(seed?: string): string { - if (seed) return crypto.createHash("sha256").update(seed).digest("hex"); - const hostname = os.hostname(); - const platform = os.platform(); - const arch = os.arch(); - const cpu = getCpuModel(); - let username = "unknown-user"; - try { - username = os.userInfo().username; - } catch { - /* ignore */ - } - return crypto - .createHash("sha256") - .update(`${hostname}|${platform}|${arch}|${cpu}|${username}`) - .digest("hex"); -} - -// ── Bootstrap ────────────────────────────────────────────────────────────── - -const bootstrapInflight = new Map>(); - -async function bootstrapJwt( - baseUrl: string, - fingerprint: string, - signal?: AbortSignal | null, - dispatcher?: Dispatcher -): Promise<{ jwt: string; expiresAt: number }> { - const existing = bootstrapInflight.get(fingerprint); - if (existing) return existing; - - const url = `${baseUrl}${BOOTSTRAP_PATH}`; - const controller = new AbortController(); - const timer = setTimeout(() => { - const err = new Error(`mimocode bootstrap timeout after ${BOOTSTRAP_TIMEOUT_MS}ms`); - err.name = "TimeoutError"; - controller.abort(err); - }, BOOTSTRAP_TIMEOUT_MS); - const onSignal = signal ? () => controller.abort(signal.reason) : null; - if (signal && onSignal) signal.addEventListener("abort", onSignal, { once: true }); - - const promise = (async () => { - try { - const resp = dispatcher - ? await undiciFetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ client: fingerprint }), - signal: controller.signal, - dispatcher, - }) - : await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ client: fingerprint }), - signal: controller.signal, - }); - if (!resp.ok) { - const body = await resp.text().catch(() => ""); - throw new Error(`Bootstrap failed: ${resp.status} ${body.slice(0, 200)}`); - } - const data = (await resp.json()) as { jwt?: string }; - if (!data.jwt) throw new Error("Bootstrap response missing jwt field"); - return { jwt: data.jwt, expiresAt: parseJwtExp(data.jwt) }; - } finally { - clearTimeout(timer); - if (signal && onSignal) signal.removeEventListener("abort", onSignal); - bootstrapInflight.delete(fingerprint); - } - })(); - - bootstrapInflight.set(fingerprint, promise); - return promise; -} - -// ── Model Rewriting ──────────────────────────────────────────────────────── - -function rewriteModelName(model: string): string { - const idx = model.lastIndexOf("/"); - return idx >= 0 ? model.slice(idx + 1) : model; -} - -// ── Executor ─────────────────────────────────────────────────────────────── - -export class MimocodeExecutor extends BaseExecutor { - private accounts: AccountState[] = []; - // 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(); - - constructor() { - super("mimocode", { format: "openai" }); - this.baseUrl = this.getBaseUrls()[0] || "https://api.xiaomimimo.com"; - this.accounts.push({ - fingerprint: generateFingerprint(), - jwt: "", - expiresAt: 0, - cooldownUntil: 0, - consecutiveFails: 0, - // #3837/#5521 backward compat: default the per-account proxy to null (not undefined), - // mirroring the syncAccountsFromCredentials() account builder, so an executor with no - // accountProxies config still exposes `acct.proxy === null` on every account. - proxy: null, - }); - } - - private getProxyDispatcher(fingerprint: string): Dispatcher | undefined { - const proxyUrl = this.proxyUrlMap.get(fingerprint); - if (!proxyUrl) return undefined; - return createProxyDispatcher(proxyUrl); - } - - private fetchWithProxy(url: string, init: RequestInit, fingerprint: string): Promise { - const dispatcher = this.getProxyDispatcher(fingerprint); - if (dispatcher) { - // undici fetch returns undici.Response which is structurally compatible with - // the global Response but nominally different — same pattern as proxyFetch.ts - const undiciFn = undiciFetch as unknown as ( - url: string, - init: RequestInit & { dispatcher?: unknown } - ) => Promise; - return undiciFn(url, { ...init, dispatcher }); - } - return fetch(url, init); - } - - private syncAccountsFromCredentials(credentials: ProviderCredentials): void { - const psd = credentials?.providerSpecificData; - const fingerprints = psd?.fingerprints; - - const accountProxies = psd?.accountProxies as AccountProxyConfig[] | undefined; - - // #5521: build the per-fingerprint proxy URL map that getProxyDispatcher() consumes - // to route each account's traffic through its own SOCKS5/HTTP dispatcher. - if (Array.isArray(accountProxies)) { - for (const entry of accountProxies) { - if (entry?.fingerprint && entry?.proxy?.host) { - const { - type = "socks5", - host, - port, - username, - password, - } = entry.proxy as { - type?: string; - host: string; - port?: number; - username?: string; - password?: string; - }; - const resolvedPort = port ?? (type === "socks5" ? 1080 : 8080); - const auth = username - ? `${encodeURIComponent(username)}:${password ? encodeURIComponent(password) : ""}@` - : ""; - this.proxyUrlMap.set(entry.fingerprint, `${type}://${auth}${host}:${resolvedPort}`); - } - } - } - - // #3837: register any newly-advertised fingerprints as accounts. - if (Array.isArray(fingerprints)) { - const existing = new Set(this.accounts.map((a) => a.fingerprint)); - for (const fp of fingerprints) { - if (typeof fp === "string" && !existing.has(fp)) { - this.accounts.push({ - fingerprint: fp, - jwt: "", - expiresAt: 0, - cooldownUntil: 0, - consecutiveFails: 0, - proxy: null, - }); - existing.add(fp); - } - } - } - - // #3837: resolve each account's structured proxy config from accountProxies. - const proxyMap = Array.isArray(accountProxies) - ? new Map(accountProxies.map((ap) => [ap.fingerprint, ap.proxy] as const)) - : null; - for (const acct of this.accounts) { - if (proxyMap) { - const entry = proxyMap.get(acct.fingerprint); - acct.proxy = entry !== undefined ? (entry ?? null) : null; - } else { - acct.proxy = null; - } - } - } - - private async getJwtForAccount( - account: AccountState, - signal?: AbortSignal | null - ): Promise { - if (isAccountReady(account)) return account.jwt; - const dispatcher = this.getProxyDispatcher(account.fingerprint); - const result = await bootstrapJwt(this.baseUrl, account.fingerprint, signal, dispatcher); - account.jwt = result.jwt; - account.expiresAt = result.expiresAt; - return account.jwt; - } - - private pickAccount(): AccountState { - return pickRotatableAccount(this.accounts, this, isAccountReady); - } - - private markCooldown(account: AccountState): void { - markAccountCooldown(account); - } - - private markSuccess(account: AccountState): void { - markAccountSuccess(account); - } - - /** - * POST the request with the account's JWT; on auth failure (401/403), re-bootstrap - * the account's JWT and retry once. Mutates `headers`' Authorization in place. - */ - private async fetchWithAuthRetry( - url: string, - headers: Record, - reqBody: unknown, - signal: AbortSignal | null | undefined, - account: AccountState, - log: ExecuteInput["log"] - ): Promise { - const jwt = await this.getJwtForAccount(account, signal); - headers["Authorization"] = `Bearer ${jwt}`; - - const resp = await this.fetchWithProxy( - url, - { - method: "POST", - headers, - body: JSON.stringify(reqBody), - signal: signal ?? undefined, - }, - account.fingerprint - ); - if (resp.status !== 401 && resp.status !== 403) return resp; - - // On auth failure, re-bootstrap this account and retry once - log?.warn?.( - "MIMOCODE", - `Auth failed (${resp.status}) on account ${account.fingerprint.slice(0, 8)}…` - ); - account.jwt = ""; - account.expiresAt = 0; - account.consecutiveFails = 0; - const freshJwt = await this.getJwtForAccount(account, signal); - headers["Authorization"] = `Bearer ${freshJwt}`; - return this.fetchWithProxy( - url, - { - method: "POST", - headers, - body: JSON.stringify(reqBody), - signal: signal ?? undefined, - }, - account.fingerprint - ); - } - - /** - * Gate 429/400 statuses before the success path: a 429 — or a 400 carrying - * MiMoCode's rate-limit text — puts the account on cooldown and rotates; any other - * 400 fails fast with the sanitized upstream error (#2101/#4976, see - * handleBadRequest). Returns "rotate", a fail-fast Response, or null to proceed. - */ - private async gateRetryableStatus( - resp: Response, - account: AccountState, - log: ExecuteInput["log"] - ): Promise<"rotate" | Response | null> { - if (resp.status === 429) { - this.markCooldown(account); - log?.warn?.( - "MIMOCODE", - `Rate limited on account ${account.fingerprint.slice(0, 8)}, trying next…` - ); - return "rotate"; - } - if (resp.status !== 400) return null; - return (await this.handleBadRequest(resp, account, log)) ?? "rotate"; - } - - /** - * Classify a 400 response body (#2101/#4976). - * - * #4976: MiMoCode signals throttling via a non-standard 400 whose body carries - * rate-limit semantics (e.g. "Detected high-frequency non-compliant requests from - * you.") instead of a 429 — same RATE_LIMIT_TEXT_PATTERNS as accountFallback.ts's - * checkFallbackError(), so the two call sites never disagree on what counts as - * throttling. That case puts the account on cooldown and returns `null` (rotate). - * - * #2101: any other 400 is a genuinely malformed request that fails identically on - * every account — rotating would waste N round-trips, cooldown every account (a - * provider-wide outage for parallel requests), and hide the real diagnostic behind - * a generic exhaustion error. That case returns a fail-fast 400 Response carrying - * the sanitized upstream message, without touching cooldown/success state. - */ - private async handleBadRequest( - resp: Response, - account: AccountState, - log: ExecuteInput["log"] - ): Promise { - const bodyText = await resp.text().catch(() => ""); - - if (RATE_LIMIT_TEXT_PATTERNS.some((p) => p.test(bodyText))) { - this.markCooldown(account); - log?.warn?.( - "MIMOCODE", - `Rate-limit-style 400 on account ${account.fingerprint.slice(0, 8)}, trying next…` - ); - return null; - } - - log?.warn?.( - "MIMOCODE", - `Malformed request (400) on account ${account.fingerprint.slice(0, 8)}, not retrying` - ); - let upstreamMessage = bodyText; - try { - const parsed = JSON.parse(bodyText) as { error?: { message?: string } }; - if (parsed?.error?.message) upstreamMessage = parsed.error.message; - } catch { - /* body wasn't JSON — use raw text */ - } - const errorBody = buildErrorBody(400, sanitizeErrorMessage(upstreamMessage || "Bad request")); - return new Response(MimocodeExecutor.encoder.encode(JSON.stringify(errorBody)), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - - buildUrl( - _model: string, - _stream: boolean, - _urlIndex = 0, - _credentials?: ProviderCredentials | null - ): string { - return `${this.baseUrl.replace(/\/$/, "")}${CHAT_PATH}`; - } - - buildHeaders( - _credentials: ProviderCredentials, - stream = true, - _clientHeaders?: Record | null, - _model?: string - ): Record { - const headers: Record = { - "Content-Type": "application/json", - "X-Mimo-Source": MIMO_SOURCE, - "User-Agent": USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)], - }; - if (stream) headers["Accept"] = "text/event-stream, application/json"; - return headers; - } - - transformRequest( - model: string, - body: unknown, - _stream: boolean, - _credentials?: ProviderCredentials | null - ): unknown { - if (typeof body === "object" && body !== null) { - const withModel = { ...(body as Record), model: rewriteModelName(model) }; - return injectSystemMarker(withModel); - } - return body; - } - - async testConnection( - _credentials: ProviderCredentials, - _signal?: AbortSignal | null, - log?: ExecuteInput["log"] - ): Promise { - try { - this.syncAccountsFromCredentials(_credentials); - const account = this.accounts[0]; - const jwt = await this.getJwtForAccount(account, _signal); - const resp = await this.fetchWithProxy( - this.buildUrl("mimo-auto", false), - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${jwt}`, - "X-Mimo-Source": MIMO_SOURCE, - }, - body: JSON.stringify( - injectSystemMarker({ - model: "mimo-auto", - messages: [{ role: "user", content: "ping" }], - stream: false, - }) - ), - signal: _signal ?? undefined, - }, - account.fingerprint - ); - return resp.status === 200; - } catch { - log?.warn?.("MIMOCODE", "testConnection network error"); - return false; - } - } - - async execute(input: ExecuteInput): Promise<{ - response: Response; - url: string; - headers: Record; - transformedBody: unknown; - }> { - const { model, stream, body, signal, log } = input; - const encoder = MimocodeExecutor.encoder; - - if (signal?.aborted) { - return { - response: new Response( - encoder.encode( - JSON.stringify({ - error: { message: "Request aborted", type: "abort", code: "ABORTED" }, - }) - ), - { status: 499, headers: { "Content-Type": "application/json" } } - ), - url: this.buildUrl(model, stream), - headers: this.buildHeaders(input.credentials, stream), - transformedBody: body, - }; - } - - const url = this.buildUrl(model, stream); - const reqBody = this.transformRequest(model, body, stream, input.credentials); - - 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); - - // 429/400 gating (#2101/#4976): cooldown+rotate, fail fast, or proceed. - const gate = await this.gateRetryableStatus(resp, account, log); - if (gate === "rotate") continue; - if (gate) { - return { - response: gate, - url, - headers: this.buildHeaders(input.credentials, stream), - transformedBody: reqBody, - }; - } - - this.markSuccess(account); - const respHeaders: Record = {}; - resp.headers.forEach((v, k) => { - respHeaders[k] = v; - }); - return { - response: resp as unknown as Response, - url, - headers: respHeaders, - 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) { - log?.error?.("MIMOCODE", `Executor error: ${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, - }; - } - } - } - - return { - response: new Response( - encoder.encode( - JSON.stringify({ - error: { - message: "All accounts exhausted", - type: "upstream_error", - code: "NO_ACCOUNTS", - }, - }) - ), - { status: 502, headers: { "Content-Type": "application/json" } } - ), - url, - headers: this.buildHeaders(input.credentials, stream), - transformedBody: body, - }; - } -} - -export default MimocodeExecutor; diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts index 26be70bf2c..6f7a08fbaf 100644 --- a/open-sse/executors/opencode.ts +++ b/open-sse/executors/opencode.ts @@ -31,7 +31,7 @@ interface OpencodeAccountState extends RotatableAccount { fingerprint: string; } -const EFFORT_LEVELS = ["low", "medium", "high", "max"] as const; +const EFFORT_LEVELS = ["none", "low", "high", "max"] as const; /** * Models that work WITHOUT any API key on the free/noauth opencode tier. @@ -62,7 +62,7 @@ const OPENCODE_FREE_MODELS = new Set([ * Models on opencode-go that support effort-tier aliases. Each entry maps the * canonical base id to the set of effort suffixes the upstream supports. * - * - deepseek-v4-pro: all four tiers (low/medium/high/max) + * - DeepSeek V4 Pro and Flash: none/low/high/max * - glm-5.2: high/max only (Z.AI maps these through the reasoning plane; * low/medium are not supported on the OpenAI transport) * - mimo-v2.5: high/max only (same reasoning; Xiaomi MiMo does not document @@ -70,12 +70,12 @@ const OPENCODE_FREE_MODELS = new Set([ * - #8353 OpenCode Go registry effort variants (exact suffix sets from * `opencode models opencode-go --verbose`; MiniMax M3 excluded — different * thinking-mode mapping): - * deepseek-v4-flash high/max; grok-4.5 low/medium/high; hy3 none/low/high; - * kimi-k3 max; qwen3.6-plus / qwen3.7-max / qwen3.7-plus high/max + * grok-4.5 low/medium/high; hy3 none/low/high; kimi-k3 max; + * qwen3.6-plus / qwen3.7-max / qwen3.7-plus high/max */ const EFFORT_TIERS: Record = { "deepseek-v4-pro": EFFORT_LEVELS, - "deepseek-v4-flash": ["high", "max"], + "deepseek-v4-flash": EFFORT_LEVELS, "glm-5.2": ["high", "max"], "mimo-v2.5": ["high", "max"], "grok-4.5": ["low", "medium", "high"], @@ -378,7 +378,9 @@ export class OpencodeExecutor extends BaseExecutor { credentials: ProviderCredentials | null, stream = true, clientHeaders?: Record | null, - model?: string + model?: string, + _health?: Record, + body?: unknown ) { const headers: Record = { "Content-Type": "application/json" }; // #8467: honor Extra API Keys rotation via BaseExecutor.resolveEffectiveKey. @@ -403,16 +405,12 @@ export class OpencodeExecutor extends BaseExecutor { headers["Accept"] = "text/event-stream"; } - // Opt-in (#5997): synthesize OpenCode CLI identity headers the client did not send. - // Cloudflare in front of opencode.ai/zen/go 403s server-side (VPS) requests lacking - // CLI identity, but the forward-only default is deliberate — fabricating a WRONG - // value risks upstream rejection (#5720 regressed with "opencode/local"), and this - // is deployment-specific. So it stays OFF by default and the VPS operator enables it - // with OPENCODE_SYNTHESIZE_CLI_HEADERS=true (values env-overridable). Client-supplied - // headers take precedence, EXCEPT User-Agent: a non-CLI client UA (curl/SDK) is - // replaced with the synthesized CLI UA because opencode.ai's free tier rejects - // generic client UAs from datacenter IPs (FreeUsageLimitError 429). - const synthesizeCli = /^(1|true|yes|on)$/i.test( + // Synthesize OpenCode CLI identity headers by default so Cloudflare in front of + // opencode.ai/zen doesn't 429 VPS requests lacking CLI identity. Opt-out via + // OPENCODE_SYNTHESIZE_CLI_HEADERS=false. Client-supplied headers always win; + // User-Agent is replaced with the CLI UA unless the client already sends one that + // looks like the OpenCode CLI. Default values match 9router's proven defaults. + const synthesizeCli = !/^(0|false|no|off)$/i.test( process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS?.trim() ?? "" ); const cliDefaults = synthesizeCli @@ -423,17 +421,30 @@ export class OpencodeExecutor extends BaseExecutor { userAgent: process.env[envUAKey]?.trim() || process.env.OPENCODE_USER_AGENT?.trim() || - "opencode-cli/1.0.0", - client: process.env.OPENCODE_CLIENT?.trim() || "cli", - project: process.env.OPENCODE_PROJECT?.trim() || "default", + "opencode", + client: process.env.OPENCODE_CLIENT?.trim() || "desktop", + project: process.env.OPENCODE_PROJECT?.trim() || "global", }; })() : undefined; if (clientHeaders || cliDefaults) { + const b = body && typeof body === "object" ? (body as Record) : null; forwardOpencodeClientHeaders(headers, clientHeaders ?? {}, { synthesizeRequestId: true, cliDefaults, + sessionBody: b + ? { + model: typeof b.model === "string" ? b.model : undefined, + system: b.system, + messages: Array.isArray(b.messages) + ? (b.messages as Array<{ role?: string; content?: unknown }>) + : undefined, + tools: Array.isArray(b.tools) + ? (b.tools as Array<{ name?: string; function?: { name?: string } }>) + : undefined, + } + : undefined, }); } diff --git a/open-sse/executors/qwen-web.ts b/open-sse/executors/qwen-web.ts index 57036a3cbb..4a312d7003 100644 --- a/open-sse/executors/qwen-web.ts +++ b/open-sse/executors/qwen-web.ts @@ -58,6 +58,7 @@ const MODEL_ALIASES: Record = { "qwen3-plus": "qwen3.7-plus", "qwen3-max": "qwen3.7-max", "qwen3-flash": "qwen3.6-plus", + "qwen3.8-max-preview": "qwen3.8-max", // Note: `qwen3-coder-plus` is a real upstream model id (Qwen3-Coder) and // must NOT be aliased — the previous `"qwen3-coder-plus": "qwen3.7-max"` // entry silently rewrote valid coder requests to the wrong model. @@ -67,7 +68,7 @@ const MODEL_ALIASES: Record = { }; const DEFAULT_MODEL = "qwen3.7-max"; -const REQUIRED_THINKING_MODELS = new Set(["qwen3.8-max-preview"]); +const REQUIRED_THINKING_MODELS = new Set(["qwen3.8-max"]); function mapModel(modelId: string): string { return MODEL_ALIASES[modelId] || modelId; diff --git a/open-sse/executors/xai.ts b/open-sse/executors/xai.ts index de8fcf7425..3b5a5eef03 100644 --- a/open-sse/executors/xai.ts +++ b/open-sse/executors/xai.ts @@ -3,6 +3,7 @@ import { PROVIDERS } from "../config/constants.ts"; import { getModelTargetFormat } from "../config/providerModels.ts"; import { isResponsesEndpointPath } from "../utils/responsesEndpoint.ts"; import { chatRequestToXaiResponses } from "@/lib/providers/xai/translators/openai-chat.ts"; +import { capXaiRequestHistory } from "../services/xaiMessageCap.ts"; type JsonRecord = Record; @@ -157,7 +158,8 @@ export class XaiExecutor extends BaseExecutor { } // Keep model id from the routed request when the translator left it empty. if (out.model == null && model) out.model = model; - return out; + // After chat→Responses expansion, `input` is what xAI counts toward 800. + return capXaiRequestHistory(out); } let modelId = typeof out.model === "string" ? out.model : model; @@ -185,7 +187,7 @@ export class XaiExecutor extends BaseExecutor { if (effort) out.reasoning_effort = effort; } - return out; + return capXaiRequestHistory(out); } } diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 9fe9d2277e..da4e1ccfd4 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -69,8 +69,24 @@ function isValidPathSegment(segment: string): boolean { return !segment.includes("..") && !segment.includes("//"); } +/** + * A `.opus` file is Opus audio in an Ogg container (RFC 7845) — the same bytes + * a client would otherwise name `.ogg`. Whisper-compatible upstreams pick the + * decoder from the *filename* and their allow-list + * (`flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm`) has no `opus`, so + * `note.opus` 400s while byte-identical `note.ogg` succeeds. Since + * `/v1/audio/speech` emits `audio/opus` for `response_format=opus`, clients + * round-tripping their own voice notes hit this constantly. Relabel to the + * container that actually describes the bytes. + */ +function normalizeUploadExtension(name: string): string { + return name.replace(/\.opus$/i, ".ogg"); +} + function getUploadedFileName(file: Blob & { name?: unknown }): string { - return typeof file.name === "string" && file.name.length > 0 ? file.name : "audio.wav"; + return typeof file.name === "string" && file.name.length > 0 + ? normalizeUploadExtension(file.name) + : "audio.wav"; } /** diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 0235589a5b..6188c44352 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -53,6 +53,7 @@ import { import { shouldUseNativeCodexPassthrough, shouldUseNativeXaiResponsesPassthrough, + shouldUseNativeOpenAICompatibleResponsesPassthrough, stampNativeResponsesPassthroughBody, redactPassthroughThinkingSignatures, isClaudeCodeSemanticPassthroughRequest, @@ -165,6 +166,7 @@ import { buildCapabilityMismatchMessage, } from "@/shared/constants/capabilities/capabilityFilter.ts"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags.ts"; +import { resolveNoAuthEchoModel } from "./chatCore/noAuthEchoModel.ts"; import { REASONING_BUFFER_MIN_TRIGGER, buildReasoningProbeTruncatedResponse, @@ -308,6 +310,7 @@ import { } from "./chatCore/upstreamTimeouts.ts"; import { getModelNormalizeToolCallId, getModelPreserveOpenAIDeveloperRole } from "@/lib/db/models"; import { getProviderCredentials, extractSessionAffinityKey } from "@/sse/services/auth"; +import { assertExclusiveConnectionLeaseFence } from "@/lib/db/exclusiveConnectionLeases"; import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { guardrailRegistry } from "@/lib/guardrails"; @@ -463,8 +466,10 @@ export async function handleChatCore({ skipUpstreamRetry = false, createPiiTransform = null, correlationId = null, + conversationId = null, modelPinned = false, skipResourcePressureGuard = false, + managedLease = null, }) { let { provider, model, extendedContext } = modelInfo; if (!skipResourcePressureGuard) { @@ -510,6 +515,46 @@ export async function handleChatCore({ : null; return credentialConnectionId || connectionId || null; }; + const assertManagedLeaseFence = (attemptConnectionId: string | null | undefined) => { + if (!managedLease) return; + if (!attemptConnectionId) { + throw Object.assign(new Error("Managed lease connection is unavailable"), { + code: "LEASE_CONNECTION_MISMATCH", + status: 409, + }); + } + const fence = assertExclusiveConnectionLeaseFence({ + leaseOwnerId: managedLease.context.leaseOwnerId, + generation: managedLease.context.generation, + apiKeyId: managedLease.apiKeyId, + connectionId: attemptConnectionId, + }); + if (fence.kind === "VALID") return; + const code = + fence.kind === "REQUIRED" + ? "LEASE_REQUIRED" + : fence.kind === "STALE" + ? "LEASE_FENCE_STALE" + : fence.kind === "AUTHORIZATION_MISMATCH" + ? "LEASE_AUTHORIZATION_MISMATCH" + : "LEASE_CONNECTION_MISMATCH"; + throw Object.assign(new Error("Managed lease request fence rejected the dispatch"), { + code, + status: 409, + }); + }; + const isManagedLeaseFenceError = (error: unknown): boolean => + managedLease !== null && + typeof (error as { code?: unknown })?.code === "string" && + String((error as { code: string }).code).startsWith("LEASE_"); + const managedLeaseFenceErrorResult = (error: unknown) => { + const code = (error as { code: string }).code; + return { + ...createErrorResult(409, "Managed lease request fence rejected the dispatch", null, code), + errorType: "lease_error", + errorCode: code, + }; + }; let tokensCompressed: number | null = null; body = injectSystemPrompt(body); // ── Per-endpoint custom system prompt (port of upstream #2063) ── @@ -676,6 +721,12 @@ export async function handleChatCore({ copilotCompatibleReasoning, clientResponseFormat, } = resolveChatCoreRequestFormat({ clientRawRequest, body, provider, userAgent }); + const nativeOpenAICompatibleResponsesPassthrough = shouldUseNativeOpenAICompatibleResponsesPassthrough({ + provider, + sourceFormat, + endpointPath, + providerSpecificData: credentials?.providerSpecificData, + }); const responsesInputItems = Array.isArray(body?.input) ? body.input : []; const customToolNames = collectCustomToolNamesForSourceFormat( sourceFormat, @@ -795,8 +846,12 @@ export async function handleChatCore({ customModelTargetFormat, providerSpecificData: credentials?.providerSpecificData, nativeXaiResponsesPassthrough, + nativeOpenAICompatibleResponsesPassthrough, }); - const nativeResponsesPassthrough = nativeCodexPassthrough || nativeXaiResponsesPassthrough; + const nativeResponsesPassthrough = + nativeCodexPassthrough || + nativeXaiResponsesPassthrough || + nativeOpenAICompatibleResponsesPassthrough; const initialProviderRequest = body && typeof body === "object" && !Array.isArray(body) @@ -822,6 +877,7 @@ export async function handleChatCore({ providerRequest: initialProviderRequest, stage: "registered", correlationId, + sessionTag: conversationId || null, }) || generateRequestId(); // Initialize rate limit settings from persisted DB (once, lazy) @@ -884,12 +940,15 @@ export async function handleChatCore({ const isCodexResponsesEcho = (isResponsesEndpoint || sourceFormat === FORMATS.OPENAI_RESPONSES) && isCodexOriginatedHeaders(clientRawRequest?.headers); - const echoModel = + let echoModel = (settings.echoRequestedModelName === true || isCodexResponsesEcho) && typeof requestedModel === "string" && requestedModel ? requestedModel : null; + // Auto-echo the listing-valid form for bare requests to noAuth catalog + // providers so clients validating response.model against /v1/models don't warn. + echoModel = resolveNoAuthEchoModel(requestedModel, provider) ?? echoModel; const detailedLoggingEnabled = !noLogEnabled && (settings.call_log_pipeline_enabled === true || @@ -951,7 +1010,11 @@ export async function handleChatCore({ noLogEnabled, correlationId, modelPinned, - sessionTag: explicitSessionIdHeader, + // Resolved conversationId (open-sse/services/conversationTracker.ts) wins when + // present — it's populated for every request now, not just ones where the + // client explicitly sent x-omniroute-session-id. The raw header remains a + // fallback for any caller that somehow bypassed conversationId resolution. + sessionTag: conversationId || explicitSessionIdHeader, }); // Primary path: merge client model id + alias target so config on either key applies; resolved @@ -2082,13 +2145,19 @@ export async function handleChatCore({ if (nativeResponsesPassthrough) { translatedBody = stampNativeResponsesPassthroughBody( body, - nativeCodexPassthrough ? "codex" : "xai" + nativeCodexPassthrough + ? "codex" + : nativeXaiResponsesPassthrough + ? "xai" + : "openai-compatible" ); log?.debug?.( "FORMAT", nativeCodexPassthrough ? "native codex passthrough enabled" - : "native xAI Responses Agent Tools passthrough enabled" + : nativeXaiResponsesPassthrough + ? "native xAI Responses Agent Tools passthrough enabled" + : "native openai-compatible Responses passthrough enabled" ); } else if (isClaudeCodeCompatible) { let normalizedForCc = { ...body }; @@ -2253,7 +2322,13 @@ export async function handleChatCore({ // - tools with a name → converted to function format in-place before translation // - tools without a name AND without .function → dropped (unconvertible) // This must happen before translateRequest, which validates and throws on unknown types. - if (provider?.startsWith("openai-compatible-") && Array.isArray(translatedBody.tools)) { + // Skip normalization when we are in native openai-compatible Responses passthrough mode + // to preserve native tool definitions (exec with lark grammar, collaboration namespace, etc.). + if ( + !nativeOpenAICompatibleResponsesPassthrough && + provider?.startsWith("openai-compatible-") && + Array.isArray(translatedBody.tools) + ) { const normalized = normalizeOpenAICompatibleTools( translatedBody.tools as Record[], sourceFormat @@ -2850,6 +2925,8 @@ export async function handleChatCore({ connectionId, clientResponseFormat, clientAbortSignal: clientRawRequest?.signal, + allowCompletedToolHandoffGrace: isCodexResponsesEcho, + clientDisconnectGracePeriodMs: STREAM_DISCONNECT_GRACE_PERIOD_MS, }); const dedupRequestBody = { ...translatedBody, model: `${provider}/${model}`, stream }; @@ -2869,6 +2946,7 @@ export async function handleChatCore({ credentials, log, bypassDefaultToolLimit: isOpencodeClient, + isOpencodeClient, }); updatePendingScope(pendingScope, { @@ -2940,6 +3018,7 @@ export async function handleChatCore({ updatePendingScope(pendingScope, { stage: "rate_limit_slot_acquired", }); + assertManagedLeaseFence(attemptConnectionId); return executeWithUpstreamStartTimeout({ executor, provider, @@ -3010,6 +3089,7 @@ export async function handleChatCore({ // Codex 429 account-rotation failover (disabled for context-relay so combo.ts can inject handoff) if ( provider === "codex" && + !managedLease && comboStrategy !== "context-relay" && res.response.status === 429 && attempts < maxAttempts - 1 @@ -3172,6 +3252,7 @@ export async function handleChatCore({ body: unknown ): Promise | null> => { try { + assertManagedLeaseFence(attemptConnectionId); const retryRaw = await executeWithUpstreamStartTimeout({ executor, provider, @@ -3486,6 +3567,7 @@ export async function handleChatCore({ } } catch (error) { trackPendingRequest(model, provider, connectionId, false); + if (isManagedLeaseFenceError(error)) return managedLeaseFenceErrorResult(error); if (isSemaphoreCapacityError(error)) { appendRequestLog({ model, @@ -3697,6 +3779,7 @@ export async function handleChatCore({ // stay aligned if this block ever runs after a path that mutates body.model (e.g. fallback). try { const retryModelId = String(translatedBody.model || effectiveModel); + assertManagedLeaseFence(getExecutionConnectionId(getExecutionCredentials())); const retryResult = normalizeExecutorResult( await runWithCapture(providerRequestCapture, () => executor.execute({ @@ -3734,6 +3817,7 @@ export async function handleChatCore({ upstreamErrorParsed = false; // Let it be parsed downstream } } catch (retryErr) { + if (isManagedLeaseFenceError(retryErr)) return managedLeaseFenceErrorResult(retryErr); // Refresh succeeded but the retry leg failed (network blip, AbortError, // executor throw). Don't swallow — the operator-visible signal "the user // saw 401 even though auth was actually fixed" is much more confusing diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index bbe049cd63..63a12c3038 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -16,6 +16,7 @@ import { emit } from "@/lib/events/eventBus"; import type { RequestCompletedPayload, RequestFailedPayload } from "@/lib/events/types"; import { saveCallLog } from "@/lib/usageDb"; import { FORMATS } from "../../translator/formats.ts"; +import { takeEarlyKeepaliveBytes } from "../../utils/earlyKeepaliveByteBuffer.ts"; import { cloneBoundedChatLogPayload, truncateForLog } from "./logTruncation.ts"; import { attachLogMeta } from "./cacheUsageMeta.ts"; @@ -244,6 +245,22 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt message: error, }; } + // withEarlyStreamKeepalive writes keepalive/startup/error frames directly + // to the client from OUTSIDE this handler's own reqLogger, so they never + // reach reqLogger.appendConvertedChunk. correlationId is the only thing + // both sides share (see earlyKeepaliveByteBuffer.ts's file doc for why); + // merge here, once, right before persistence, prepended in send order. + if (detailedLoggingEnabled && correlationId) { + const earlyClientBytes = takeEarlyKeepaliveBytes(correlationId); + if (earlyClientBytes.length > 0) { + const existingStreamChunks = + (pipelinePayloads.streamChunks as { client?: string[] } | undefined) ?? {}; + pipelinePayloads.streamChunks = { + ...existingStreamChunks, + client: [...earlyClientBytes, ...(existingStreamChunks.client ?? [])], + }; + } + } } saveCallLog({ diff --git a/open-sse/handlers/chatCore/executorClientHeaders.ts b/open-sse/handlers/chatCore/executorClientHeaders.ts index e2a77bf36e..2088bcbd99 100644 --- a/open-sse/handlers/chatCore/executorClientHeaders.ts +++ b/open-sse/handlers/chatCore/executorClientHeaders.ts @@ -13,13 +13,19 @@ export function buildExecutorClientHeaders( userAgent?: string | null ) { const normalized: Record = {}; + const isLeaseControlHeader = (key: string) => { + const lowerKey = key.toLowerCase(); + return lowerKey === "x-omniroute-lease-owner" || lowerKey === "x-omniroute-lease-generation"; + }; if (headers instanceof Headers) { headers.forEach((value, key) => { + if (isLeaseControlHeader(key)) return; normalized[key] = value; }); } else if (headers && typeof headers === "object") { for (const [key, value] of Object.entries(headers)) { + if (isLeaseControlHeader(key)) continue; if (typeof value === "string") { normalized[key] = value; } diff --git a/open-sse/handlers/chatCore/noAuthEchoModel.ts b/open-sse/handlers/chatCore/noAuthEchoModel.ts new file mode 100644 index 0000000000..76993cefdb --- /dev/null +++ b/open-sse/handlers/chatCore/noAuthEchoModel.ts @@ -0,0 +1,25 @@ +/** + * chatCore noAuth-provider echoModel aliasing (PR #10571). + * + * Pure helper extracted from chatCore: for a bare (unprefixed) requested model + * routed to a no-auth catalog provider (e.g. `opencode`), returns the + * `/` listing-valid form so that clients validating + * `response.model` against the provider's entry in `/v1/models` (which lists + * models under the provider's alias prefix) don't warn/reject. Returns null + * when the request does not match that shape, leaving any existing echoModel + * decision (e.g. the #1311 opt-in echo) untouched. + */ +import { REGISTRY } from "../../config/providerRegistry.ts"; +import { isNoAuthProviderKey } from "@/shared/utils/noAuthProviders.ts"; + +export function resolveNoAuthEchoModel( + requestedModel: unknown, + provider: string | null | undefined +): string | null { + if (typeof requestedModel !== "string" || !requestedModel) return null; + if (requestedModel.includes("/")) return null; + if (!isNoAuthProviderKey(provider)) return null; + + const alias = (provider && REGISTRY[provider]?.alias) || provider; + return `${alias}/${requestedModel}`; +} diff --git a/open-sse/handlers/chatCore/passthroughHelpers.ts b/open-sse/handlers/chatCore/passthroughHelpers.ts index 943dd3f5ae..352415ed89 100644 --- a/open-sse/handlers/chatCore/passthroughHelpers.ts +++ b/open-sse/handlers/chatCore/passthroughHelpers.ts @@ -46,10 +46,33 @@ export function shouldUseNativeXaiResponsesPassthrough({ export function stampNativeResponsesPassthroughBody( body: Record, - mode: "codex" | "xai" + mode: "codex" | "xai" | "openai-compatible" ): Record { if (mode === "codex") return { ...body, _nativeCodexPassthrough: true }; - return { ...body, _nativeXaiResponsesPassthrough: true }; + if (mode === "xai") return { ...body, _nativeXaiResponsesPassthrough: true }; + return { ...body, _nativeOpenAICompatibleResponsesPassthrough: true }; +} + +export function shouldUseNativeOpenAICompatibleResponsesPassthrough({ + provider, + sourceFormat, + endpointPath, + providerSpecificData, +}: { + provider?: string | null; + sourceFormat?: string | null; + endpointPath?: string | null; + providerSpecificData?: unknown; +}): boolean { + if (!provider?.startsWith("openai-compatible-")) return false; + if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return false; + if (providerSpecificData && typeof providerSpecificData === "object") { + const psd = providerSpecificData as Record; + if (psd.apiType === "responses" || psd._omnirouteForceResponsesUpstream === true) { + return true; + } + } + return false; } /** diff --git a/open-sse/handlers/chatCore/targetFormat.ts b/open-sse/handlers/chatCore/targetFormat.ts index 27ce3aa3d8..f2d0b7160d 100644 --- a/open-sse/handlers/chatCore/targetFormat.ts +++ b/open-sse/handlers/chatCore/targetFormat.ts @@ -25,6 +25,7 @@ export function resolveChatCoreTargetFormat(opts: { customModelTargetFormat: string | undefined; providerSpecificData: unknown; nativeXaiResponsesPassthrough?: boolean; + nativeOpenAICompatibleResponsesPassthrough?: boolean; }) { const { provider, @@ -34,6 +35,7 @@ export function resolveChatCoreTargetFormat(opts: { customModelTargetFormat, providerSpecificData, nativeXaiResponsesPassthrough = false, + nativeOpenAICompatibleResponsesPassthrough = false, } = opts; const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, resolvedModel); @@ -68,7 +70,9 @@ export function resolveChatCoreTargetFormat(opts: { (apiFormat === "responses" && !customOpenAICompatible ? FORMATS.OPENAI_RESPONSES : inferredAgentRouterTargetFormat || providerTargetFormat); - if (nativeXaiResponsesPassthrough) targetFormat = FORMATS.OPENAI_RESPONSES; + if (nativeXaiResponsesPassthrough || nativeOpenAICompatibleResponsesPassthrough) { + targetFormat = FORMATS.OPENAI_RESPONSES; + } return { alias, targetFormat }; } diff --git a/open-sse/handlers/chatCore/upstreamBody.ts b/open-sse/handlers/chatCore/upstreamBody.ts index f2d8368c8c..52d1ddcc1b 100644 --- a/open-sse/handlers/chatCore/upstreamBody.ts +++ b/open-sse/handlers/chatCore/upstreamBody.ts @@ -87,6 +87,76 @@ function truncateToolList( return bodyToSend; } +// OpenCode's AI SDK file-part serializer omits `image_url.detail`, which makes wide, text-dense +// screenshots fall back to low-detail vision sampling upstream. Gated on `isOpencodeClient` (the +// request's User-Agent / `x-opencode-*` header signal, not the `provider` field — `provider` is +// the upstream target and can be anything regardless of which client sent the request) so this +// override doesn't change the detail default for non-OpenCode callers on any provider. +function defaultImageDetail(bodyToSend: Body, isOpencodeClient: boolean): Body { + if (!isOpencodeClient) return bodyToSend; + + let nextBody = bodyToSend; + + if (Array.isArray(bodyToSend.messages)) { + const messages = bodyToSend.messages.map((message) => { + if (!message || typeof message !== "object" || Array.isArray(message)) return message; + const messageRecord = message as Record; + if (!Array.isArray(messageRecord.content)) return message; + + let changed = false; + const content = messageRecord.content.map((part) => { + if (!part || typeof part !== "object" || Array.isArray(part)) return part; + const partRecord = part as Record; + const imageUrl = partRecord.image_url; + if ( + partRecord.type !== "image_url" || + !imageUrl || + typeof imageUrl !== "object" || + Array.isArray(imageUrl) + ) { + return part; + } + + const imageUrlRecord = imageUrl as Record; + if (imageUrlRecord.detail !== undefined) return part; + changed = true; + return { ...partRecord, image_url: { ...imageUrlRecord, detail: "high" } }; + }); + + return changed ? { ...messageRecord, content } : message; + }); + + if (messages.some((message, index) => message !== bodyToSend.messages?.[index])) { + nextBody = { ...nextBody, messages }; + } + } + + if (Array.isArray(bodyToSend.input)) { + const input = bodyToSend.input.map((item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const itemRecord = item as Record; + if (!Array.isArray(itemRecord.content)) return item; + + let changed = false; + const content = itemRecord.content.map((part) => { + if (!part || typeof part !== "object" || Array.isArray(part)) return part; + const partRecord = part as Record; + if (partRecord.type !== "input_image" || partRecord.detail !== undefined) return part; + changed = true; + return { ...partRecord, detail: "high" }; + }); + + return changed ? { ...itemRecord, content } : item; + }); + + if (input.some((item, index) => item !== bodyToSend.input?.[index])) { + nextBody = { ...nextBody, input }; + } + } + + return nextBody; +} + // Inject prompt_cache_key only for providers that support it. async function injectPromptCacheKey( bodyToSend: Body, @@ -117,6 +187,7 @@ export async function prepareUpstreamBody(opts: { targetFormat: string; credentials: CredentialsLike; bypassDefaultToolLimit?: boolean; + isOpencodeClient?: boolean; log?: LoggerLike; }): Promise { const { @@ -126,6 +197,7 @@ export async function prepareUpstreamBody(opts: { targetFormat, credentials, bypassDefaultToolLimit = false, + isOpencodeClient = false, log, } = opts; @@ -157,6 +229,7 @@ export async function prepareUpstreamBody(opts: { model: payloadRuleModel, log, }); + bodyToSend = defaultImageDetail(bodyToSend, isOpencodeClient); bodyToSend = truncateToolList(bodyToSend, provider, bypassDefaultToolLimit ?? false, log); const connectionCacheOverride = resolveConnectionCacheOverride(credentials?.providerSpecificData); bodyToSend = await injectPromptCacheKey( diff --git a/open-sse/handlers/embeddingStructuredInput.ts b/open-sse/handlers/embeddingStructuredInput.ts index 79d7d8a136..1183c9e5a3 100644 --- a/open-sse/handlers/embeddingStructuredInput.ts +++ b/open-sse/handlers/embeddingStructuredInput.ts @@ -1,6 +1,18 @@ import { MAX_EMBEDDING_INLINE_TOTAL_BYTES } from "@/shared/validation/schemas/apiV1"; import type { EmbeddingMultimodalItem } from "@/shared/validation/schemas/apiV1"; import type { EmbeddingProvider } from "../config/embeddingRegistry.ts"; +import { + isCanonicalEmbeddingItem, + isJinaMergedContentGroup, + isJinaNativeDoc, + isJinaNativeEmbeddingItem, + isPlainObject, +} from "@/shared/validation/jinaNativeEmbeddingInput"; +import { + isGeminiNativeContent, + isGeminiNativeEmbedRequest, + isGeminiNativePart, +} from "@/shared/validation/geminiNativeEmbeddingInput"; const AGGREGATE_SIZE_ERROR = "decoded inline media must not exceed 16 MiB per request"; @@ -101,12 +113,165 @@ async function prepareJinaInput( }); } +/** + * Mixed batches: keep Jina-native docs / strings intact and only translate + * OmniRoute canonical `{ type, source }` items into Jina ImageDoc/TextDoc. + */ +export async function prepareJinaMixedEmbeddingInput( + input: unknown[], + fetchMedia: StructuredEmbeddingFetchOptions["fetchMedia"] +): Promise { + const out: unknown[] = []; + for (const item of input) { + if (typeof item === "string" || isJinaNativeEmbeddingItem(item)) { + out.push(item); + continue; + } + if (isCanonicalEmbeddingItem(item)) { + const [translated] = await prepareJinaInput( + [item as EmbeddingMultimodalItem], + fetchMedia + ); + out.push(translated); + continue; + } + out.push(item); + } + return out; +} + function mapGeminiTaskType(value: unknown): unknown { if (value === "retrieval.query") return "RETRIEVAL_QUERY"; if (value === "retrieval.passage") return "RETRIEVAL_DOCUMENT"; return value; } +function geminiNativeUrl(model: string, method: "embedContent" | "batchEmbedContents"): string { + return `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:${method}`; +} + +function geminiRequestExtras(body: Record): Record { + const extras: Record = {}; + if (body.dimensions !== undefined) extras.output_dimensionality = body.dimensions; + if (body.task !== undefined) extras.task_type = mapGeminiTaskType(body.task); + return extras; +} + +function embeddingValues(entry: unknown): unknown[] { + if (!entry || typeof entry !== "object") return []; + const values = (entry as { values?: unknown }).values; + return Array.isArray(values) ? values : []; +} + +function normalizeGeminiEmbedContentResponse(data: Record): Record { + return { + object: "list", + data: [{ object: "embedding", embedding: embeddingValues(data.embedding), index: 0 }], + usage: { prompt_tokens: 0, total_tokens: 0 }, + }; +} + +function normalizeGeminiBatchResponse(data: Record): Record { + const embeddings = Array.isArray(data.embeddings) ? data.embeddings : []; + return { + object: "list", + data: embeddings.map((entry, index) => ({ + object: "embedding", + embedding: embeddingValues(entry), + index, + })), + usage: { prompt_tokens: 0, total_tokens: 0 }, + }; +} + +function dataUriToInlineData(value: string): { mime_type: string; data: string } | null { + const match = /^data:([^;,]+);base64,(.+)$/i.exec(value.trim()); + if (!match) return null; + return { mime_type: match[1], data: match[2] }; +} + +async function mediaStringToGeminiPart( + raw: string, + fallbackMime: string, + fetchMedia: StructuredEmbeddingFetchOptions["fetchMedia"] +): Promise> { + const trimmed = raw.trim(); + const fromDataUri = dataUriToInlineData(trimmed); + if (fromDataUri) return { inline_data: fromDataUri }; + if (/^https:\/\//i.test(trimmed)) { + const fetched = await fetchMedia(trimmed); + if (!fetched.contentType) { + throw new Error("Remote embedding media must include a Content-Type header"); + } + return { + inline_data: { + mime_type: fetched.contentType, + data: fetched.buffer.toString("base64"), + }, + }; + } + return { inline_data: { mime_type: fallbackMime, data: trimmed } }; +} + +async function jinaDocToGeminiPart( + item: Record, + fetchMedia: StructuredEmbeddingFetchOptions["fetchMedia"] +): Promise> { + if (typeof item.text === "string") return { text: item.text }; + if (typeof item.image === "string") { + return mediaStringToGeminiPart(item.image, "image/png", fetchMedia); + } + if (typeof item.audio === "string") { + return mediaStringToGeminiPart(item.audio, "audio/mpeg", fetchMedia); + } + if (typeof item.video === "string") { + return mediaStringToGeminiPart(item.video, "video/mp4", fetchMedia); + } + if (typeof item.pdf === "string") { + return mediaStringToGeminiPart(item.pdf, "application/pdf", fetchMedia); + } + throw new Error("Unsupported Jina-native embedding item for Gemini"); +} + +/** + * Map one OpenAI-compat input element to one Gemini Content. + * A fused multimodal item (native parts / Jina content group / one canonical + * object) stays one Content. Do not dump sibling array elements into parts. + */ +async function itemToGeminiContent( + item: unknown, + fetchMedia: StructuredEmbeddingFetchOptions["fetchMedia"] +): Promise> { + if (typeof item === "string") return { parts: [{ text: item }] }; + if (isGeminiNativeEmbedRequest(item)) { + return (item as { content: Record }).content; + } + if (isGeminiNativeContent(item)) { + return item as Record; + } + if (isGeminiNativePart(item)) { + return { parts: [item as Record] }; + } + if (isJinaMergedContentGroup(item)) { + const parts: Record[] = []; + for (const chunk of (item as { content: unknown[] }).content) { + if (isPlainObject(chunk)) parts.push(await jinaDocToGeminiPart(chunk, fetchMedia)); + } + return { parts }; + } + if (isJinaNativeDoc(item) && isPlainObject(item)) { + return { parts: [await jinaDocToGeminiPart(item, fetchMedia)] }; + } + if (isCanonicalEmbeddingItem(item)) { + const [part] = await prepareGeminiParts( + [item as EmbeddingMultimodalItem], + fetchMedia + ); + return { parts: [part] }; + } + throw new Error("Unsupported Gemini embedding input item"); +} + async function prepareGeminiParts( items: EmbeddingMultimodalItem[], fetchMedia: StructuredEmbeddingFetchOptions["fetchMedia"] @@ -118,19 +283,17 @@ async function prepareGeminiParts( }); } -function normalizeGeminiResponse(data: Record): Record { - const embedding = data.embedding as { values?: unknown } | undefined; - return { - object: "list", - data: [{ object: "embedding", embedding: embedding?.values ?? [], index: 0 }], - usage: { prompt_tokens: 0, total_tokens: 0 }, - }; +function normalizeEmbeddingInputItems(input: unknown): unknown[] { + if (Array.isArray(input)) return input; + if (input === undefined || input === null) return []; + return [input]; } /** * Translate OmniRoute's provider-neutral structured input into a documented - * provider-native transport. Each top-level canonical array is one logical - * multimodal item for Gemini and one vector-per-item batch for Jina. + * provider-native transport. Each top-level input array element is one + * embedding. Gemini Embedding 2 fuses multiple parts inside one Content; + * N OpenAI `input` items must become N vectors via batchEmbedContents. */ export async function prepareStructuredEmbeddingRequest( provider: EmbeddingProvider, @@ -139,25 +302,46 @@ export async function prepareStructuredEmbeddingRequest( token: string, options: StructuredEmbeddingFetchOptions ): Promise { - const items = body.input as EmbeddingMultimodalItem[]; + const items = normalizeEmbeddingInputItems(body.input); if (provider.structuredInputProtocol === "jina-v1") { return { url: provider.baseUrl, - body: { ...body, model, input: await prepareJinaInput(items, options.fetchMedia) }, + body: { + ...body, + model, + input: await prepareJinaInput(items as EmbeddingMultimodalItem[], options.fetchMedia), + }, }; } if (provider.structuredInputProtocol === "gemini-embed-content") { - const parts = await prepareGeminiParts(items, options.fetchMedia); - const request: Record = { - content: { parts }, - }; - if (body.dimensions !== undefined) request.output_dimensionality = body.dimensions; - if (body.task !== undefined) request.task_type = mapGeminiTaskType(body.task); + const contents: Record[] = []; + for (const item of items) { + contents.push(await itemToGeminiContent(item, options.fetchMedia)); + } + if (contents.length === 0) { + throw new Error("Gemini embedding input must contain at least one item"); + } + const extras = geminiRequestExtras(body); + const authHeader = { name: "x-goog-api-key", value: token }; + if (contents.length === 1) { + return { + url: geminiNativeUrl(model, "embedContent"), + body: { content: contents[0], ...extras }, + authHeader, + normalizeResponse: normalizeGeminiEmbedContentResponse, + }; + } return { - url: `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:embedContent`, - body: request, - authHeader: { name: "x-goog-api-key", value: token }, - normalizeResponse: normalizeGeminiResponse, + url: geminiNativeUrl(model, "batchEmbedContents"), + body: { + requests: contents.map((content) => ({ + model: `models/${model}`, + content, + ...extras, + })), + }, + authHeader, + normalizeResponse: normalizeGeminiBatchResponse, }; } throw new Error(`Provider ${provider.id} has no structured embedding input translator`); diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index 0945e3138a..df9fe26283 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -32,10 +32,20 @@ import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { hasStructuredEmbeddingInput, + prepareJinaMixedEmbeddingInput, prepareStructuredEmbeddingRequest, } from "./embeddingStructuredInput.ts"; import { MAX_EMBEDDING_INLINE_ITEM_BYTES } from "@/shared/validation/schemas/apiV1"; import { markAccountUnavailable } from "../../src/sse/services/auth.ts"; +import { + collectJinaNativeModalities, + isJinaNativeEmbeddingInput, +} from "@/shared/validation/jinaNativeEmbeddingInput"; +import { + collectGeminiNativeModalities, + isGeminiEmbedding2Family, + isGeminiNativeEmbeddingInput, +} from "@/shared/validation/geminiNativeEmbeddingInput"; interface ClientRawRequest { endpoint: string; @@ -171,7 +181,15 @@ export async function handleEmbedding({ typeof item === "object" && item !== null && "type" in item ) : []; - if (structuredItems.length > 0) { + const nativeModalities = [ + ...(isJinaNativeEmbeddingInput(body.input) + ? collectJinaNativeModalities(body.input) + : []), + ...(isGeminiNativeEmbeddingInput(body.input) + ? collectGeminiNativeModalities(body.input) + : []), + ].filter((modality) => modality !== "text"); + if (structuredItems.length > 0 || nativeModalities.length > 0) { const supportedModalities = getEmbeddingModelModalities(providerConfig, model); if (!supportedModalities) { return { @@ -180,12 +198,24 @@ export async function handleEmbedding({ error: `Embedding model ${body.model} does not advertise structured embedding input support`, }; } - const unsupported = structuredItems.find((item) => !supportedModalities.includes(item.type)); - if (unsupported) { + const unsupportedCanonical = structuredItems.find( + (item) => !supportedModalities.includes(item.type) + ); + if (unsupportedCanonical) { return { success: false, status: 400, - error: `Embedding model ${body.model} does not support ${unsupported.type} input`, + error: `Embedding model ${body.model} does not support ${unsupportedCanonical.type} input`, + }; + } + const unsupportedNative = nativeModalities.find( + (modality) => !supportedModalities.includes(modality) + ); + if (unsupportedNative) { + return { + success: false, + status: 400, + error: `Embedding model ${body.model} does not support ${unsupportedNative} input`, }; } } @@ -278,7 +308,39 @@ export async function handleEmbedding({ }; } - if (hasStructuredEmbeddingInput(body.input)) { + // Jina v5 Omni native docs ({ text }, { image: url|base64 }, { content: [...] }) + // must reach api.jina.ai unchanged. Do not fetch those image URLs or collapse + // to string[]. Canonical { type, source } items still go through the translator. + const jinaNative = isJinaNativeEmbeddingInput(body.input); + const geminiNative = isGeminiNativeEmbeddingInput(body.input); + const canonicalStructured = hasStructuredEmbeddingInput(body.input); + const passThroughJinaNative = + providerConfig.structuredInputProtocol === "jina-v1" && jinaNative && !canonicalStructured; + // gemini-embedding-2 aggregates a string[] on Google's OpenAI shim into one + // vector. Always use embedContent / batchEmbedContents so N input items + // become N embeddings. Native multimodal parts take the same path. + const useGeminiNativeTransport = + providerConfig.structuredInputProtocol === "gemini-embed-content" && + (isGeminiEmbedding2Family(model) || + canonicalStructured || + geminiNative || + jinaNative); + + if (providerConfig.structuredInputProtocol === "jina-v1" && jinaNative && canonicalStructured) { + try { + const mixed = Array.isArray(body.input) ? body.input : [body.input]; + upstreamBody.input = await prepareJinaMixedEmbeddingInput(mixed, async (url) => { + const result = await fetchRemoteImage(url, { + guard: "public-only", + maxBytes: MAX_EMBEDDING_INLINE_ITEM_BYTES, + pinDns: true, + }); + return { buffer: result.buffer, contentType: result.contentType || null }; + }); + } catch (error) { + return { success: false, status: 400, error: sanitizeErrorMessage(error) }; + } + } else if (useGeminiNativeTransport || (!passThroughJinaNative && canonicalStructured)) { if (!model) { return { success: false, diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index f8f8b3d001..5d76987286 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -1,20 +1,5 @@ import { randomUUID } from "crypto"; -/** - * Image Generation Handler - * - * Handles POST /v1/images/generations requests. - * Proxies to upstream image generation providers using OpenAI-compatible format. - * - * Request format (OpenAI-compatible): - * { - * "model": "openai/gpt-image-2", - * "prompt": "a beautiful sunset over mountains", - * "n": 1, - * "size": "1024x1024", - * "quality": "standard", // optional: "standard" | "hd" - * "response_format": "url" // optional: "url" | "b64_json" - * } - */ +/** Image generation handler for POST /v1/images/generations (OpenAI-compatible). */ import { getImageProvider, parseImageModel } from "../config/imageRegistry.ts"; import { HTTP_STATUS } from "../config/constants.ts"; @@ -51,10 +36,6 @@ import { } from "@/shared/utils/fetchTimeout"; import { sanitizeErrorMessage, sanitizeUpstreamDetails } from "../utils/error.ts"; -// --- Per-provider handlers (extracted to co-located files in PR-#4582-batch) --- -// Imported locally so internal callers (handleImageGeneration / handleImageEdit) -// resolve to a real binding. extractMarkdownImageUrls + CHATGPT_WEB_IMAGE_ID_RE -// are still used by handleImageEdit below, so they are imported (not re-defined). import { handleSDWebUIImageGeneration } from "./imageGeneration/providers/sdWebUI.ts"; import { handleHyperbolicImageGeneration } from "./imageGeneration/providers/hyperbolic.ts"; import { handleHuggingFaceImageGeneration } from "./imageGeneration/providers/huggingface.ts"; @@ -70,12 +51,14 @@ import { extractMarkdownImageUrls, CHATGPT_WEB_IMAGE_ID_RE, } from "./imageGeneration/providers/chatgptWeb.ts"; +import { handleGeminiWebImageGeneration } from "./imageGeneration/providers/geminiWeb.ts"; import { handleNvidiaNimImageGeneration } from "./imageGeneration/providers/nvidiaNim.ts"; import { handleSegmindImageGeneration } from "./imageGeneration/providers/segmind.ts"; import { handleDesignerWebImageGeneration } from "./imageGeneration/providers/designerWeb.ts"; import { handleMinimaxImageGeneration } from "./imageGeneration/providers/minimax.ts"; import { handleAdobeFireflyImageGeneration } from "./imageGeneration/providers/adobeFirefly.ts"; import { handleAlibabaImageGeneration } from "./imageGeneration/providers/alibabaImage.ts"; +import { handleAiHordeImageGeneration } from "./imageGeneration/providers/aihorde.ts"; import { applyPollinationsAnonymousFallback, reportPollinationsAnonOutcome, @@ -373,6 +356,18 @@ export async function handleImageGeneration({ }); } + if (providerConfig.format === "aihorde") { + return handleAiHordeImageGeneration({ + model, + provider, + providerConfig, + body, + credentials, + log, + signal, + }); + } + if (providerConfig.format === "gemini-image") { return handleGeminiImageGeneration({ model, providerConfig, body, credentials, log }); } @@ -499,6 +494,19 @@ export async function handleImageGeneration({ }); } + // #10466: Gemini Web session image generation (Nano Banana) + if (providerConfig.format === "gemini-web") { + return handleGeminiWebImageGeneration({ + model, + provider, + body, + credentials, + log, + signal, + clientHeaders, + }); + } + if (providerConfig.format === "designer-web") { return handleDesignerWebImageGeneration({ model, @@ -2689,6 +2697,22 @@ export function saveImageErrorResult({ error, requestBody = null, path = "/v1/images/generations", + // #10494: opt-in signal for executeImageWithCredentialFallback — set by a + // provider handler when the failure is account/session-specific (expired + // or blocked credentials) rather than a generic request/provider error, so + // the retry loop tries the next eligible account even when the upstream + // status isn't a plain 401. Defaults to unset (existing 401-only behavior + // for every other provider is unchanged). + retryable = undefined, +}: { + provider: string; + model: string; + status: number; + startTime: number; + error: unknown; + requestBody?: unknown; + path?: string; + retryable?: boolean; }) { saveCallLog({ method: "POST", @@ -2705,6 +2729,7 @@ export function saveImageErrorResult({ success: false, status, error, + ...(retryable !== undefined ? { retryable } : {}), }; } diff --git a/open-sse/handlers/imageGeneration/providers/aihorde.ts b/open-sse/handlers/imageGeneration/providers/aihorde.ts new file mode 100644 index 0000000000..4d39270c03 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/aihorde.ts @@ -0,0 +1,325 @@ +import { saveCallLog } from "@/lib/usageDb"; +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; +import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; +import { sleep } from "../../../utils/sleep.ts"; +import { sanitizeErrorMessage } from "../../../utils/error.ts"; +import { + AI_HORDE_ANONYMOUS_KEY, + AI_HORDE_API_BASE, + AI_HORDE_CATALOG_FETCH_TIMEOUT_MS, + AI_HORDE_CLIENT_AGENT, + aiHordeImageCatalog, +} from "../../../services/aihordeImageCatalog.ts"; +import { + extractHordeSourceB64, + mapHordeGenerateRequest, + stripHordeModelPrefix, +} from "./aihordeMapRequest.ts"; + +const GENERATE_TIMEOUT_MS = 600_000; +const POLL_INTERVAL_MS = 1_000; +// Per-call bound for the Horde API's own submit/check/status/cancel calls +// (a fixed, trusted host — no SSRF guard needed, just a hard timeout so a +// hung upstream cannot stall a request indefinitely). Individual calls are +// additionally capped to whatever remains of the overall generation deadline. +const HORDE_API_CALL_TIMEOUT_MS = 30_000; +// R2 image downloads point at a URL Horde's response supplies, not a fixed +// OmniRoute-controlled host, so they get the SSRF host guard too. +const HORDE_IMAGE_DOWNLOAD_TIMEOUT_MS = 60_000; +const MAX_HORDE_IMAGE_BYTES = 25 * 1024 * 1024; + +function hordeHeaders(apiKey: string): Record { + return { + apikey: apiKey, + "Client-Agent": AI_HORDE_CLIENT_AGENT, + Accept: "application/json", + "Content-Type": "application/json", + }; +} + +/** Bound to whatever is left of the overall request deadline, floored so a + * near-expired deadline still gets one last bounded attempt instead of a + * zero/negative timeout. */ +function boundedTimeoutMs(deadline: number, cap: number): number { + return Math.max(1_000, Math.min(cap, deadline - Date.now())); +} + +function hordeMessage(payload: unknown, fallback: string): string { + if (payload && typeof payload === "object") { + const message = (payload as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) return message; + } + return fallback; +} + +async function safeJson(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function mapUpstreamStatus(status: number): number { + if ( + status === 400 || + status === 401 || + status === 403 || + status === 404 || + status === 429 || + status === 503 + ) { + return status; + } + return 502; +} + +function resolveHordeApiKey(credentials: { apiKey?: unknown } | null | undefined): string { + const raw = credentials?.apiKey; + return typeof raw === "string" && raw.trim() ? raw.trim() : AI_HORDE_ANONYMOUS_KEY; +} + +async function cancelHordeJob(jobId: string, apiKey: string): Promise { + try { + // Best-effort cancel — deliberately not tied to the caller's (already + // expired/aborted) signal, and given its own short timeout so a hung + // cancel-DELETE cannot itself hang the cleanup path. + await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/status/${jobId}`, { + method: "DELETE", + headers: hordeHeaders(apiKey), + guard: "none", + timeoutMs: HORDE_API_CALL_TIMEOUT_MS, + }); + } catch { + // Best-effort cancel after timeout or client disconnect. + } +} + +async function fetchHordeImageBytes( + img: string, + options: { signal?: AbortSignal | null; timeoutMs: number } +): Promise { + const value = img.trim(); + if (value.startsWith("http://") || value.startsWith("https://")) { + // Horde's response supplies this URL (a signed R2 storage link), not a + // fixed OmniRoute-controlled host — route it through the repository's + // established bounded remote-image fetch (SSRF host guard + DNS-rebinding + // pin, streaming byte cap, redirect limit, abort-aware timeout) instead of + // a bare fetch(). Same helper `imageGeneration.ts` already uses for other + // providers' remote image URLs. + const remote = await fetchRemoteImage(value, { + timeoutMs: options.timeoutMs, + signal: options.signal ?? undefined, + maxBytes: MAX_HORDE_IMAGE_BYTES, + }); + if (remote.buffer.length === 0) throw new Error("Horde R2 download returned an empty image"); + return remote.buffer.toString("base64"); + } + return value; +} + +export async function handleAiHordeImageGeneration({ + model, + provider, + body, + credentials, + log, + signal = null, + timeoutMs = GENERATE_TIMEOUT_MS, +}: { + model: string; + provider: string; + providerConfig?: { baseUrl?: string }; + body: Record; + credentials?: { apiKey?: unknown } | null; + log?: { + info: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; + signal?: AbortSignal | null; + /** Overridable for tests; production callers should rely on the default. */ + timeoutMs?: number; +}) { + const startTime = Date.now(); + const hordeModel = stripHordeModelPrefix(model); + const prompt = typeof body.prompt === "string" ? body.prompt : String(body.prompt ?? ""); + const apiKey = resolveHordeApiKey(credentials); + const logRequestBody = { + model: hordeModel, + prompt: prompt.slice(0, 200), + size: body.size || "1024x1024", + n: body.n || 1, + }; + // Deadline covers the FULL request lifecycle — catalog freshness check, + // job submission, polling, and image download — not just the polling + // loop. Every bounded fetch below is capped to whatever remains of it. + const deadline = startTime + timeoutMs; + + if (log) { + log.info("IMAGE", `${provider}/${hordeModel} (aihorde) | prompt: "${prompt.slice(0, 60)}..."`); + } + + try { + await aiHordeImageCatalog.ensureFresh(undefined, { + signal: signal ?? undefined, + timeoutMs: boundedTimeoutMs(deadline, AI_HORDE_CATALOG_FETCH_TIMEOUT_MS), + }); + if (aiHordeImageCatalog.hasSnapshot() && !aiHordeImageCatalog.isServed(hordeModel)) { + const error = `No Horde workers are currently serving ${hordeModel}`; + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 400, + model: `${provider}/${hordeModel}`, + provider, + duration: Date.now() - startTime, + error, + requestBody: logRequestBody, + }).catch(() => {}); + return { success: false, status: 400, error }; + } + + const sourceImage = extractHordeSourceB64(body); + const payload = mapHordeGenerateRequest(body, { sourceImage }); + const submit = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/async`, { + method: "POST", + headers: hordeHeaders(apiKey), + body: JSON.stringify(payload), + signal: signal ?? undefined, + guard: "none", + timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS), + }); + const submitBody = await safeJson(submit); + if (submit.status !== 200 && submit.status !== 202) { + const error = hordeMessage(submitBody, `Horde submit failed (${submit.status})`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: mapUpstreamStatus(submit.status), + model: `${provider}/${hordeModel}`, + provider, + duration: Date.now() - startTime, + error, + requestBody: logRequestBody, + }).catch(() => {}); + return { success: false, status: mapUpstreamStatus(submit.status), error }; + } + const jobId = + submitBody && typeof submitBody === "object" ? (submitBody as { id?: unknown }).id : null; + if (typeof jobId !== "string" || !jobId) { + return { success: false, status: 502, error: "Horde submit did not return a job id" }; + } + + let completed = false; + try { + while (true) { + if (signal?.aborted) throw new Error("Horde image generation cancelled"); + if (Date.now() >= deadline) { + throw Object.assign(new Error("Horde image generation timed out"), { status: 504 }); + } + await sleep(POLL_INTERVAL_MS); + const checkRes = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/check/${jobId}`, { + headers: hordeHeaders(apiKey), + signal: signal ?? undefined, + guard: "none", + timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS), + }); + const check = await safeJson(checkRes); + if (!checkRes.ok || !check || typeof check !== "object") { + throw Object.assign( + new Error(hordeMessage(check, `Horde check failed (${checkRes.status})`)), + { status: mapUpstreamStatus(checkRes.status) } + ); + } + const checkObj = check as Record; + if (checkObj.faulted) throw new Error("Horde marked the job as faulted"); + if (checkObj.is_possible === false) { + throw Object.assign(new Error("No Horde workers can currently fulfill this request"), { + status: 503, + }); + } + if (!checkObj.done) continue; + + const statusRes = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/status/${jobId}`, { + headers: hordeHeaders(apiKey), + signal: signal ?? undefined, + guard: "none", + timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS), + }); + const status = await safeJson(statusRes); + if (!statusRes.ok || !status || typeof status !== "object") { + throw Object.assign( + new Error(hordeMessage(status, `Horde status failed (${statusRes.status})`)), + { status: mapUpstreamStatus(statusRes.status) } + ); + } + const generations = (status as { generations?: unknown }).generations; + if (!Array.isArray(generations) || generations.length === 0) { + throw new Error("Horde status contained no generations"); + } + const images: Array<{ b64_json: string; revised_prompt: string }> = []; + for (const item of generations) { + if (!item || typeof item !== "object") continue; + const img = (item as { img?: unknown }).img; + if (typeof img !== "string" || !img) continue; + // The polling loop's deadline check only runs once per iteration + // before the poll fetches — re-check here so a deadline that + // expires during (or immediately after) polling still aborts + // before an unbounded amount of image-download work starts, and + // so the job gets cancelled via the `finally` below rather than + // silently completing over-budget. + if (Date.now() >= deadline) { + throw Object.assign(new Error("Horde image generation timed out"), { status: 504 }); + } + images.push({ + b64_json: await fetchHordeImageBytes(img, { + signal, + timeoutMs: boundedTimeoutMs(deadline, HORDE_IMAGE_DOWNLOAD_TIMEOUT_MS), + }), + revised_prompt: prompt, + }); + } + if (images.length === 0) throw new Error("Horde status contained no image payloads"); + completed = true; + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status: 200, + model: `${provider}/${hordeModel}`, + provider, + duration: Date.now() - startTime, + requestBody: logRequestBody, + responseBody: { images_count: images.length }, + }).catch(() => {}); + return { + success: true, + data: { created: Math.floor(Date.now() / 1000), data: images }, + }; + } + } finally { + if (!completed) await cancelHordeJob(jobId, apiKey); + } + } catch (err) { + const status = + err && + typeof err === "object" && + "status" in err && + typeof (err as { status: unknown }).status === "number" + ? (err as { status: number }).status + : 502; + const raw = err instanceof Error ? err.message : "Horde image generation failed"; + const error = sanitizeErrorMessage(raw); + if (log) log.error("IMAGE", `aihorde error: ${String(error).slice(0, 200)}`); + saveCallLog({ + method: "POST", + path: "/v1/images/generations", + status, + model: `${provider}/${hordeModel}`, + provider, + duration: Date.now() - startTime, + error: String(error).slice(0, 500), + requestBody: logRequestBody, + }).catch(() => {}); + return { success: false, status, error }; + } +} diff --git a/open-sse/handlers/imageGeneration/providers/aihordeMapRequest.ts b/open-sse/handlers/imageGeneration/providers/aihordeMapRequest.ts new file mode 100644 index 0000000000..17388d2963 --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/aihordeMapRequest.ts @@ -0,0 +1,124 @@ +/** + * Map OpenAI image request bodies onto AI Horde generate payloads. + */ + +const MAX_N = 4; +const MIN_DIM = 64; +const MAX_DIM = 3072; +const DIM_STEP = 64; +const DEFAULT_WIDTH = 1024; +const DEFAULT_HEIGHT = 1024; +const DEFAULT_DENOISING = 0.75; +const DEFAULT_STEPS = 20; + +const SIZE_RE = /^\s*(\d+)\s*x\s*(\d+)\s*$/i; +const DATA_URL_RE = /^data:([^;,]+)?(?:;[^,]*)?;base64,(.+)$/i; + +export function stripHordeModelPrefix(model: string): string { + const name = model.trim(); + const lower = name.toLowerCase(); + if (lower.startsWith("aihorde/")) return name.slice("aihorde/".length); + if (lower.startsWith("horde/")) return name.slice("horde/".length); + return name; +} + +export function snapHordeDim(value: number): number { + const snapped = Math.round(value / DIM_STEP) * DIM_STEP; + return Math.max(MIN_DIM, Math.min(MAX_DIM, snapped)); +} + +export function parseHordeSize(size: string | null | undefined): { width: number; height: number } { + if (!size) return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }; + const match = SIZE_RE.exec(size); + if (!match) { + throw new Error(`size must look like WIDTHxHEIGHT, got ${JSON.stringify(size)}`); + } + return { width: snapHordeDim(Number(match[1])), height: snapHordeDim(Number(match[2])) }; +} + +export function capHordeN(n: unknown): number { + if (n === null || n === undefined) return 1; + const value = Number(n); + if (!Number.isFinite(value)) { + throw new Error("n must be an integer"); + } + if (value < 1) { + throw new Error("n must be at least 1"); + } + return Math.min(Math.trunc(value), MAX_N); +} + +export function extractHordeSourceB64(body: Record): string | null { + const images = body.images; + if (Array.isArray(images) && images.length > 0) { + return coerceHordeImage(images[0]); + } + if (body.image !== undefined) return coerceHordeImage(body.image); + if (typeof body.image_url === "string" && body.image_url.trim()) { + return coerceHordeImage(body.image_url); + } + return null; +} + +function coerceHordeImage(value: unknown): string { + if (value && typeof value === "object") { + const obj = value as Record; + for (const key of ["image_url", "url", "b64_json", "image"]) { + const inner = obj[key]; + if (typeof inner === "string" && inner.trim()) return stripDataUrl(inner); + } + throw new Error("image object is missing image_url, url, b64_json, or image"); + } + if (typeof value === "string" && value.trim()) return stripDataUrl(value); + throw new Error("image must be a data URL, raw base64 string, or image object"); +} + +function stripDataUrl(value: string): string { + const match = DATA_URL_RE.exec(value.trim()); + return match ? match[2].trim() : value.trim(); +} + +export function mapHordeGenerateRequest( + body: Record, + options: { sourceImage?: string | null; steps?: number } = {} +): Record { + const prompt = body.prompt; + if (typeof prompt !== "string" || !prompt.trim()) { + throw new Error("prompt is required"); + } + + const model = body.model; + if (typeof model !== "string" || !model.trim()) { + throw new Error("model is required"); + } + const hordeModel = stripHordeModelPrefix(model); + if (!hordeModel) { + throw new Error("model is empty after stripping aihorde/horde prefix"); + } + + const size = typeof body.size === "string" ? body.size : null; + const { width, height } = parseHordeSize(size); + const payload: Record = { + prompt, + models: [hordeModel], + nsfw: false, + censor_nsfw: true, + r2: true, + shared: false, + validated_backends: true, + slow_workers: true, + allow_downgrade: true, + params: { + n: capHordeN(body.n), + width, + height, + steps: options.steps ?? DEFAULT_STEPS, + }, + }; + if (options.sourceImage) { + payload.source_image = options.sourceImage; + payload.source_processing = "img2img"; + (payload.params as Record).denoising_strength = DEFAULT_DENOISING; + } + return payload; +} diff --git a/open-sse/handlers/imageGeneration/providers/geminiWeb.ts b/open-sse/handlers/imageGeneration/providers/geminiWeb.ts new file mode 100644 index 0000000000..43c91de8fc --- /dev/null +++ b/open-sse/handlers/imageGeneration/providers/geminiWeb.ts @@ -0,0 +1,228 @@ +// Gemini Web image generation handler (#10466). +// +// Exposes the gemini-web session provider through POST /v1/images/generations. +// Follows the chatgpt-web precedent (./chatgptWeb.ts): the web-session chat +// executor is driven with an image-generation prompt, and the generated +// assets are extracted from the response. +// +// Transport: GeminiWebExecutor in image mode (x_gemini_web_image_mode). The +// executor types the prompt into gemini.google.com, captures every +// StreamGenerate frame, and returns generated-image URLs in the custom +// `x_gemini_web_image_urls` field. URLs point at lh3.googleusercontent.com +// with a `=s2048` full-resolution size directive; they are public (no +// cookies needed to fetch them). +// +// Prompting: the web UI only GENERATES images when the prompt uses a +// generation verb ("generate"/"create"/"draw"); otherwise it answers with +// web-search thumbnails. The prompt builder therefore always leads with an +// explicit generation directive (corroborated by gemini-webapi's docs). + +import { GeminiWebExecutor } from "../../../executors/gemini-web.ts"; +import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; +import { saveImageErrorResult, saveImageSuccessResult } from "../../imageGeneration.ts"; + +/** Each image is one gemini.google.com turn (~30-60s). Cap like chatgpt-web. */ +const GEMINI_WEB_IMAGE_N_MAX = 4; + +export function buildGeminiWebImagePrompt(body: Record): string { + const prompt = String(body.prompt || "").trim(); + const details: string[] = [ + `Generate an image for this prompt: ${prompt}`, + "Use the image generation model. Do not search the web for existing images.", + ]; + if (typeof body.size === "string" && body.size.trim()) { + details.push(`Requested aspect/size: ${body.size.trim()}.`); + } + if (typeof body.style === "string" && body.style.trim()) { + details.push(`Requested style: ${body.style.trim()}.`); + } + return details.join("\n"); +} + +/** + * #10494: the underlying GeminiWebExecutor's browser-automation catch paths + * classify an expired/blocked Gemini Web session as HTTP 400 ("the session + * is so expired it lands on a different page" — see gemini-web.ts's + * Playwright selector/click-timeout branch, #9407) or HTTP 500 (its generic + * automation-failure catch-all, which covers a blocked/CAPTCHA/login page + * this handler has no further way to inspect). Both statuses previously + * passed straight through to executeImageWithCredentialFallback, which only + * advances to another account on a plain 401 — so an expired/blocked + * session never triggered account fallback, contrary to #10466's + * acceptance criteria ("Expired or blocked sessions ... can fall back + * normally inside an image Combo"). HTTP 503 (missing Playwright browser — + * a host/config problem, not a per-account issue) is intentionally excluded, + * as is the local 401 this handler already returns before any account is + * selected (missing session cookie — handled by the 401 path already). + */ +export function isExpiredOrBlockedGeminiWebSession(status: number): boolean { + return status === 400 || status === 500; +} + +export async function handleGeminiWebImageGeneration({ + model, + provider, + body, + credentials, + log, + signal, + clientHeaders, + // Injectable so unit tests can drive the handler without a live Gemini + // session; production uses the real executor. + executorFactory = () => new GeminiWebExecutor(), + // Injectable for tests; production fetches the public googleusercontent URL. + imageFetcher = fetchRemoteImage, +}: { + model: string; + provider: string; + body: Record; + credentials: Record | null | undefined; + log: { + info: (scope: string, message: string) => void; + warn: (scope: string, message: string) => void; + error: (scope: string, message: string) => void; + } | null; + signal?: AbortSignal | null; + clientHeaders?: Record | null; + executorFactory?: () => { + execute: (input: Record) => Promise<{ response: Response }>; + }; + imageFetcher?: (url: string) => Promise<{ buffer: Buffer; contentType: string }>; +}) { + const startTime = Date.now(); + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: "Prompt is required for Gemini Web image generation", + }); + } + + if (!credentials?.apiKey) { + return saveImageErrorResult({ + provider, + model, + status: 401, + startTime, + error: "Gemini Web credentials missing session cookie", + }); + } + + const rawCount = Number.isInteger(body.n) && (body.n as number) > 0 ? (body.n as number) : 1; + if (rawCount > GEMINI_WEB_IMAGE_N_MAX) { + return saveImageErrorResult({ + provider, + model, + status: 400, + startTime, + error: `Gemini Web image generation supports n=1..${GEMINI_WEB_IMAGE_N_MAX} (got ${rawCount}); each n is a separate ~30-60s web turn.`, + }); + } + const requestedCount = rawCount; + if (log && requestedCount > 1) { + log.warn( + "IMAGE", + `Gemini Web returns image(s) per chat turn; requested n=${requestedCount} will run sequentially` + ); + } + + const wantsBase64 = body.response_format === "b64_json"; + const images: Array<{ url?: string; b64_json?: string }> = []; + const requestBody = { + model, + prompt: prompt.slice(0, 500), + size: body.size || undefined, + n: requestedCount, + }; + + for (let i = 0; i < requestedCount; i++) { + const executor = executorFactory(); + const result = await executor.execute({ + model, + body: { + messages: [{ role: "user", content: buildGeminiWebImagePrompt(body) }], + x_gemini_web_image_mode: true, + }, + stream: false, + credentials, + signal, + log, + clientHeaders, + }); + + const responseText = await result.response.text(); + if (result.response.status >= 400) { + return saveImageErrorResult({ + provider, + model, + status: result.response.status, + startTime, + error: responseText, + requestBody, + retryable: isExpiredOrBlockedGeminiWebSession(result.response.status), + }); + } + + let content = ""; + let urls: string[] = []; + try { + const json = JSON.parse(responseText); + content = String(json?.choices?.[0]?.message?.content || ""); + urls = Array.isArray(json?.x_gemini_web_image_urls) + ? (json.x_gemini_web_image_urls as unknown[]).filter( + (u): u is string => typeof u === "string" && /^https?:\/\//.test(u) + ) + : []; + } catch { + content = responseText; + } + + if (urls.length === 0) { + // Distinguish "refused / no image produced" from a transport failure: + // the executor returns 200 with an empty URL list when the model + // answered with text only (e.g. a policy refusal or a web-search + // answer instead of generation). Surface the assistant text so the + // caller can see WHY nothing was generated. + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `Gemini Web completed without generating an image. Assistant text: ${content.slice(0, 300) || "(empty)"}`, + requestBody, + }); + } + + for (const url of urls) { + if (!wantsBase64) { + images.push({ url }); + continue; + } + try { + const fetched = await imageFetcher(url); + images.push({ b64_json: fetched.buffer.toString("base64") }); + } catch (err) { + return saveImageErrorResult({ + provider, + model, + status: 502, + startTime, + error: `Gemini Web generated an image but OmniRoute could not download it for b64_json conversion: ${err instanceof Error ? err.message : String(err)}`, + requestBody, + }); + } + } + } + + return saveImageSuccessResult({ + provider, + model, + startTime, + requestBody, + responseBody: { images_count: images.length }, + images, + }); +} diff --git a/open-sse/handlers/jinaFoundation.ts b/open-sse/handlers/jinaFoundation.ts new file mode 100644 index 0000000000..9029aeae2f --- /dev/null +++ b/open-sse/handlers/jinaFoundation.ts @@ -0,0 +1,101 @@ +/** + * Jina Foundation API proxy. + * + * Forwards classify / segment (and similar JSON POSTs) to Jina using the same + * dashboard-or-env credentials as embeddings and rerank. + */ + +import { CORS_HEADERS } from "../utils/cors.ts"; +import { errorResponse } from "../utils/error.ts"; +import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta"; +import { generateRequestId } from "@/shared/utils/requestId"; +import { saveCallLog } from "@/lib/usageDb"; + +export interface JinaFoundationCredentials { + apiKey?: string | null; + accessToken?: string | null; + connectionId?: string | null; +} + +export interface JinaFoundationProxyOptions { + path: string; + upstreamUrl: string; + body: Record; + credentials: JinaFoundationCredentials | null; + provider?: string; + model?: string | null; +} + +export async function handleJinaFoundationProxy( + options: JinaFoundationProxyOptions +): Promise { + const startTime = Date.now(); + const provider = options.provider || "jina-ai"; + const token = options.credentials?.apiKey || options.credentials?.accessToken; + const connectionId = options.credentials?.connectionId || null; + + if (!token) { + return errorResponse(401, `No credentials for Jina provider: ${provider}`); + } + + try { + const res = await fetch(options.upstreamUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(options.body), + }); + + const text = await res.text(); + let parsed: unknown = null; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + parsed = { error: text.slice(0, 500) }; + } + + saveCallLog({ + method: "POST", + path: options.path, + status: res.status, + model: options.model || `${provider}${options.path}`, + provider, + duration: Date.now() - startTime, + tokens: { prompt_tokens: 0, completion_tokens: 0 }, + connectionId, + ...(res.ok + ? {} + : { + error: + (parsed as { message?: string; error?: { message?: string } } | null)?.message || + (parsed as { error?: { message?: string } } | null)?.error?.message || + text.slice(0, 500), + }), + }).catch(() => {}); + + if (!res.ok) { + const err = parsed as { message?: string; error?: { message?: string } | string } | null; + const message = + err?.message || + (typeof err?.error === "string" ? err.error : err?.error?.message) || + `Provider returned HTTP ${res.status}`; + return errorResponse(res.status, message); + } + + const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" }); + attachOmniRouteMetaHeaders(headers, { + provider, + model: options.model || provider, + costUsd: 0, + latencyMs: Date.now() - startTime, + requestId: generateRequestId(), + }); + return new Response(JSON.stringify(parsed), { status: 200, headers }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return errorResponse(500, `Jina request failed: ${message}`); + } +} diff --git a/open-sse/handlers/openrouterTranscription.ts b/open-sse/handlers/openrouterTranscription.ts index 3c2f7796cb..474fd41321 100644 --- a/open-sse/handlers/openrouterTranscription.ts +++ b/open-sse/handlers/openrouterTranscription.ts @@ -21,6 +21,10 @@ import { upstreamErrorResponse } from "./audioTranscription.ts"; export function resolveOpenRouterAudioFormat(file: Blob & { name?: unknown }): string { const fileName = typeof file.name === "string" ? file.name.toLowerCase() : ""; const extension = fileName.includes(".") ? fileName.split(".").pop() || "" : ""; + // `.opus` is Ogg-encapsulated Opus (RFC 7845). Without this it matched + // neither the extension list nor the MIME map below and fell through to the + // "wav" default, so Opus bytes were announced to the upstream as WAV. + if (extension === "opus") return "ogg"; if (["wav", "mp3", "flac", "m4a", "ogg", "webm", "aac"].includes(extension)) { return extension; } @@ -33,6 +37,7 @@ export function resolveOpenRouterAudioFormat(file: Blob & { name?: unknown }): s "audio/x-flac": "flac", "audio/mp4": "m4a", "audio/ogg": "ogg", + "audio/opus": "ogg", "audio/webm": "webm", "audio/aac": "aac", }; diff --git a/open-sse/handlers/rerank.ts b/open-sse/handlers/rerank.ts index 175116e65d..747e1ce506 100644 --- a/open-sse/handlers/rerank.ts +++ b/open-sse/handlers/rerank.ts @@ -292,6 +292,7 @@ export async function handleRerank({ duration: Date.now() - startTime, tokens: { prompt_tokens: 0, completion_tokens: 0 }, responseBody: { results_count: Array.isArray(result?.results) ? result.results.length : 0 }, + connectionId, }).catch(() => {}); const headers = new Headers({ ...CORS_HEADERS, "Content-Type": "application/json" }); diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index eaa311f0cf..407393966d 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -10,6 +10,7 @@ import { caseInsensitiveToolNameLookup, restoreOpenAIToolNames, } from "../translator/helpers/toolCallHelper.ts"; +import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts"; type JsonRecord = Record; @@ -701,9 +702,10 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco for (const tool of messageObj.tool_calls) { const toolObj = toRecord(tool); const fn = toRecord(toolObj.function); + const rawId = toString(toolObj.id, `call_${Date.now()}`); content.push({ type: "tool_use", - id: toString(toolObj.id, `call_${Date.now()}`), + id: sanitizeToolId(rawId), name: toString(fn.name), input: typeof fn.arguments === "string" ? JSON.parse(fn.arguments || "{}") : fn.arguments || {}, diff --git a/open-sse/handlers/search.ts b/open-sse/handlers/search.ts index 5f11d34c53..42974dc8fa 100644 --- a/open-sse/handlers/search.ts +++ b/open-sse/handlers/search.ts @@ -6,7 +6,8 @@ import { randomUUID } from "crypto"; * Routes to search providers with automatic failover: * serper-search, brave-search, perplexity-search, exa-search, tavily-search, * firecrawl, google-pse-search, linkup-search, searchapi-search, - * youcom-search, searxng-search, ollama-search, zai-search, duckduckgo-free + * youcom-search, searxng-search, ollama-search, zai-search, jina-search, + * duckduckgo-free * * Request format: * { @@ -21,6 +22,7 @@ import { getSearchProvider, type SearchProviderConfig } from "../config/searchRe import { buildPerplexityRequest, parsePerplexitySearchOptions } from "./search/perplexitySearch.ts"; import * as fcSearch from "./search/firecrawlSearch.ts"; import { type FirecrawlSearchEnvelope } from "./search/firecrawlSearch.ts"; +import { buildJinaSearchRequest, extractJinaSearchItems } from "./search/jinaSearch.ts"; import { freeWebSearch } from "../services/freeWebSearch.ts"; import { saveCallLog } from "@/lib/usageDb"; import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; @@ -625,6 +627,7 @@ const requestBuilders: Record = { "youcom-search": buildYouComRequest, "searxng-search": buildSearxngRequest, "ollama-search": buildOllamaRequest, + "jina-search": buildJinaSearchRequest, }; function buildRequest( @@ -1202,6 +1205,7 @@ const responseNormalizers: Record = { "youcom-search": normalizeYouComResponse, "searxng-search": normalizeSearxngResponse, "ollama-search": normalizeOllamaResponse, + "jina-search": normalizeJinaSearchResponse, }; function normalizeResponse( @@ -1216,6 +1220,30 @@ function normalizeResponse( return { results: [], totalResults: null }; } +function normalizeJinaSearchResponse( + data: unknown, + _query: string, + _searchType: string +): { results: SearchResult[]; totalResults: number | null } { + const now = new Date().toISOString(); + const items = extractJinaSearchItems(data); + const results = items.map((item, idx) => + makeResult( + "jina-search", + { + title: item.title, + url: item.url, + snippet: item.description || item.snippet || "", + full_text: item.content || item.text, + text_format: "markdown", + }, + idx, + now + ) + ); + return { results, totalResults: results.length }; +} + export async function handleSearch(options: SearchHandlerOptions): Promise { const { query, diff --git a/open-sse/handlers/search/jinaSearch.ts b/open-sse/handlers/search/jinaSearch.ts new file mode 100644 index 0000000000..1dacb7764a --- /dev/null +++ b/open-sse/handlers/search/jinaSearch.ts @@ -0,0 +1,69 @@ +/** + * Jina Search (s.jina.ai) request builder + response normalizer. + * + * Uses the same Bearer token as the Jina Foundation API. OmniRoute does not + * add a third dashboard card — credentials come from jina-ai / jina-reader / + * JINA_AI_API_KEY. + */ + +import type { SearchProviderConfig } from "../../config/searchRegistry.ts"; + +export interface JinaSearchRequestParams { + query: string; + maxResults: number; + token?: string | null; + country?: string; + language?: string; + offset?: number; +} + +export interface JinaSearchNormalizeItem { + title?: string; + url?: string; + description?: string; + snippet?: string; + content?: string; + text?: string; +} + +export function buildJinaSearchRequest( + config: SearchProviderConfig, + params: JinaSearchRequestParams +): { url: string; init: RequestInit } { + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json", + }; + if (params.token) { + headers.Authorization = `Bearer ${params.token}`; + } + + const body: Record = { + q: params.query, + num: params.maxResults, + }; + if (params.country) body.gl = params.country; + if (params.language) body.hl = params.language; + if (typeof params.offset === "number" && params.offset > 0) { + body.page = params.offset; + } + + return { + url: config.baseUrl.endsWith("/") ? config.baseUrl : `${config.baseUrl}/`, + init: { + method: "POST", + headers, + body: JSON.stringify(body), + }, + }; +} + +export function extractJinaSearchItems(data: unknown): JinaSearchNormalizeItem[] { + if (Array.isArray(data)) return data as JinaSearchNormalizeItem[]; + if (data && typeof data === "object") { + const record = data as { data?: unknown; results?: unknown }; + if (Array.isArray(record.data)) return record.data as JinaSearchNormalizeItem[]; + if (Array.isArray(record.results)) return record.results as JinaSearchNormalizeItem[]; + } + return []; +} diff --git a/open-sse/mcp-server/README.md b/open-sse/mcp-server/README.md index cf3d5ce748..4a01ff0a5b 100644 --- a/open-sse/mcp-server/README.md +++ b/open-sse/mcp-server/README.md @@ -1,6 +1,6 @@ # OmniRoute MCP Server -> **Model Context Protocol server** that exposes OmniRoute's gateway intelligence as **109 tools** for AI agents. +> **Model Context Protocol server** that exposes OmniRoute's gateway intelligence as **107 tools** for AI agents. > > **Source of truth for the full tool catalog and REST surface:** [`docs/frameworks/MCP-SERVER.md`](../../docs/frameworks/MCP-SERVER.md). This README focuses on architecture, configuration, and integration examples; the catalog below is a summary subset. @@ -20,7 +20,7 @@ The MCP Server allows any AI agent (Claude Desktop, Cursor, VS Code Copilot, cus ┌──────────────────────────────────────────────────────────────────┐ │ OmniRoute MCP Server │ │ ┌──────────────┐ ┌─────────────────┐ ┌────────────────────┐ │ -│ │ Scope │ │ 109 MCP Tools │ │ Audit Logger │ │ +│ │ Scope │ │ 107 MCP Tools │ │ Audit Logger │ │ │ │ Enforcement │──│ (core + memory │──│ (SHA-256/SQLite) │ │ │ │ │ │ + skills + …) │ │ │ │ │ └──────────────┘ └────────┬────────┘ └────────────────────┘ │ @@ -120,23 +120,18 @@ omniroute --mcp ## Tool Reference -### Phase 1: Essential Tools (13) +### Phase 1: Essential Tools (8) -| # | Tool | Scopes | Description | -| --- | ------------------------------- | --------------------- | -------------------------------------------------------------------------- | -| 1 | `omniroute_tool_search` | `read:tools` | Discover tools from the registered MCP catalog | -| 2 | `omniroute_get_health` | `read:health` | Gateway health, uptime, memory, circuit breakers, rate limits, cache stats | -| 3 | `omniroute_list_combos` | `read:combos` | List all combos (model chains) with strategies and optional metrics | -| 4 | `omniroute_get_combo_metrics` | `read:combos` | Performance metrics for a specific combo | -| 5 | `omniroute_switch_combo` | `write:combos` | Activate or deactivate a combo for routing | -| 6 | `omniroute_create_combo` | `write:combos` | Create a validated combo through the existing combo API | -| 7 | `omniroute_check_quota` | `read:quota` | Remaining API quota per provider with token health status | -| 8 | `omniroute_route_request` | `execute:completions` | Send a chat completion through intelligent routing | -| 9 | `omniroute_cost_report` | `read:usage` | Cost report by period (session/day/week/month) with per-provider breakdown | -| 10 | `omniroute_list_models_catalog` | `read:models` | List all available models across providers with capabilities and pricing | -| 11 | `omniroute_radar_catalog` | `read:radar` | Read the local signed Radar catalog with provider/family filters | -| 12 | `omniroute_web_search` | `execute:search` | Search the web through configured search providers | -| 13 | `omniroute_web_fetch` | `execute:search` | Fetch web content through configured fetch providers | +| # | Tool | Scopes | Description | +| --- | ------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------- | +| 1 | `omniroute_get_health` | `read:health` | Gateway health, uptime, memory, circuit breakers, rate limits, cache stats + adaptive lane pressure | +| 2 | `omniroute_list_combos` | `read:combos` | List all combos (model chains) with strategies and optional metrics | +| 3 | `omniroute_get_combo_metrics` | `read:combos` | Performance metrics for a specific combo | +| 4 | `omniroute_switch_combo` | `write:combos` | Activate or deactivate a combo for routing | +| 5 | `omniroute_check_quota` | `read:quota` | Remaining API quota per provider with token health status | +| 6 | `omniroute_route_request` | `execute:completions` | Send a chat completion through intelligent routing | +| 7 | `omniroute_cost_report` | `read:usage` | Cost report by period (session/day/week/month) with per-provider breakdown | +| 8 | `omniroute_list_models_catalog` | `read:models` | List all available models across providers with capabilities and pricing | ### Phase 2: Advanced Tools (8) @@ -178,6 +173,74 @@ compression is enabled. `omniroute_compression_status` exposes those savings sep `analytics.mcpDescriptionCompression` with `source: "mcp_metadata_estimate"`, so clients do not mistake metadata shrink estimates for provider token receipts. +### Discovery & Web Tools + +| Tool | Scopes | Description | +| ----------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `omniroute_tool_search` | `read:tools` | Keyword search across the registered MCP tools; returns compact one-line signatures for token-efficient discovery | +| `omniroute_web_fetch` | `execute:search` | Fetch and extract a URL's content through the web-fetch gateway (Firecrawl, Jina Reader, Tavily, TinyFish) with automatic failover | +| `omniroute_web_search` | `execute:search` | Web search through the search gateway (Serper, Brave, Perplexity, Exa, Tavily, Google PSE, Linkup, SearchAPI, SearXNG) with failover | + +### Skills & Catalog Tools + +| Tool | Scopes | Description | +| --------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------- | +| `omniroute_agent_skills_list` | `read:catalog` | List all 42 agent skills with optional `category` (`api`\|`cli`) and `area` filters; metadata + coverage | +| `omniroute_agent_skills_get` | `read:catalog` | Full metadata + SKILL.md content for a single skill by canonical `id` | +| `omniroute_agent_skills_coverage` | `read:catalog` | Coverage stats: how many of the 22 API and 20 CLI skills have SKILL.md files on disk vs catalog totals | + +### Proxy, Pricing & Data Tools + +| Tool | Scopes | Description | +| --------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------ | +| `omniroute_oneproxy_fetch` | `read:proxies` | Fetch free proxies from the 1proxy marketplace (protocol/country/quality/limit filters) | +| `omniroute_oneproxy_rotate` | `read:proxies` | Get the next available proxy by strategy (`random` / `quality` / `sequential`) | +| `omniroute_oneproxy_stats` | `read:proxies` | Pool stats, sync status, distribution by protocol and country | +| `omniroute_sync_pricing` | `pricing:write` | Sync pricing from external sources (LiteLLM) without overwriting user-set prices; `dryRun` | +| `omniroute_db_health_check` | `read:health`, `write:resilience` | Diagnose (and optionally auto-repair) database drift — broken combo refs, orphan rows | + +### Combo & Routing Tools + +| Tool | Scopes | Description | +| -------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------- | +| `omniroute_create_combo` | `write:combos` | Register a new combo (model chain) with name, ordered model list, and optional strategy | +| `omniroute_set_routing_strategy` | `write:combos` | Update combo routing strategy at runtime (`priority` / `weighted` / `auto` / etc.) | +| `omniroute_pick_fastest_model` | `read:combos`, `read:health`, `read:usage` | Pick the fastest reliable provider-model pair from live telemetry; can apply latency routing | + +--- + +### Adaptive Admission Lane Data + +`omniroute_get_health` includes an `adaptiveAdmission` block whenever the gateway's adaptive +virtual-lane admission is active. It is a curated subset of the live admission snapshot: + +| Field | Meaning | +| ------------------ | ---------------------------------------------------------------------- | +| `virtualLanes` | Whether per-tenant virtual-lane admission is enabled | +| `pressure` | Current pressure state (e.g. `healthy`, `high`, `critical`) | +| `utilization` | Current capacity utilization (0.0–1.0) | +| `laneCount` | Number of live lanes | +| `laneQueuedCount` | Total requests queued across lanes | +| `laneQueuedCost` | Total estimated cost queued across lanes | +| `laneTenants` | Top 10 lanes by queued cost (`tenantKey`, `queuedCount`, `queuedCost`) | +| `admittedCount` | Requests admitted since boot | +| `rejectedCount` | Requests rejected since boot | +| `wouldRejectCount` | Requests that would be rejected under the current limit | +| `shutdown` | Whether the admission runtime is shutting down | + +`tenantKey` is an opaque per-API-key derived identifier, never the raw key. The block is omitted +entirely when the health endpoint reports no adaptive-admission data. + +### Skills & Tool Navigability + +The tables above cover the full `schemas/` catalog (43 entries); the authoritative reference with +scope-enforcement and transport details lives in +[`docs/frameworks/MCP-SERVER.md`](../../docs/frameworks/MCP-SERVER.md). + +Agents never need to read this file to find a capability: `omniroute_tool_search` performs keyword +search across the registered tool set and returns compact one-line signatures (token-efficient +discovery), so newly added capabilities stay discoverable at runtime. + --- ## Client Examples diff --git a/open-sse/mcp-server/__tests__/essentialTools.test.ts b/open-sse/mcp-server/__tests__/essentialTools.test.ts index b08b7f21ba..4948c47ffb 100644 --- a/open-sse/mcp-server/__tests__/essentialTools.test.ts +++ b/open-sse/mcp-server/__tests__/essentialTools.test.ts @@ -400,4 +400,120 @@ describe("omniroute_get_health handler (via MCP dispatch)", () => { const data = JSON.parse(content[0].text); expect(data.degraded).toBeUndefined(); }); + + it("should surface the curated adaptive-admission lane block when health carries it", async () => { + mockHealthSources({ + health: { + uptime: 100, + version: "3.8.50", + adaptiveAdmission: { + virtualLanes: true, + pressure: "high", + utilization: 0.72, + laneCount: 3, + laneQueuedCount: 12, + laneQueuedCost: 340, + laneTenants: [ + { tenantKey: "lane-a", queuedCount: 6, queuedCost: 200 }, + { tenantKey: "lane-b", queuedCount: 4, queuedCost: 90 }, + { tenantKey: "lane-c", queuedCount: 2, queuedCost: 50 }, + ], + admittedCount: 900, + rejectedCount: 7, + wouldRejectCount: 3, + shutdown: false, + }, + }, + resilience: { circuitBreakers: [] }, + rateLimits: { limits: [] }, + }); + + const result = await client.callTool({ name: "omniroute_get_health", arguments: {} }); + + expect(result.isError).toBeFalsy(); + const content = result.content as Array<{ type: string; text: string }>; + const data = JSON.parse(content[0].text); + expect(data.adaptiveAdmission.virtualLanes).toBe(true); + expect(data.adaptiveAdmission.pressure).toBe("high"); + expect(data.adaptiveAdmission.utilization).toBe(0.72); + expect(data.adaptiveAdmission.laneTenants).toHaveLength(3); + expect(data.adaptiveAdmission.laneTenants[0]).toEqual({ + tenantKey: "lane-a", + queuedCount: 6, + queuedCost: 200, + }); + expect(data.adaptiveAdmission.admittedCount).toBe(900); + expect(data.adaptiveAdmission.rejectedCount).toBe(7); + expect(data.adaptiveAdmission.wouldRejectCount).toBe(3); + expect(data.adaptiveAdmission.shutdown).toBe(false); + }); + + it("should coerce string lane flags and malformed lane entries defensively", async () => { + mockHealthSources({ + health: { + uptime: 1, + version: "x", + adaptiveAdmission: { + virtualLanes: "true", + shutdown: "false", + laneTenants: ["garbage", { tenantKey: "ok", queuedCount: 2, queuedCost: 7 }], + }, + }, + resilience: {}, + rateLimits: {}, + }); + + const result = await client.callTool({ name: "omniroute_get_health", arguments: {} }); + + const content = result.content as Array<{ type: string; text: string }>; + const data = JSON.parse(content[0].text); + // "true" string counts as on; "false" string must NOT invert to on. + expect(data.adaptiveAdmission.virtualLanes).toBe(true); + expect(data.adaptiveAdmission.shutdown).toBe(false); + // Malformed entries degrade to zeroed records instead of throwing. + expect(data.adaptiveAdmission.laneTenants).toEqual([ + { tenantKey: "ok", queuedCount: 2, queuedCost: 7 }, + { tenantKey: "", queuedCount: 0, queuedCost: 0 }, + ]); + }); + + it("should cap laneTenants at the top 10 by queued cost", async () => { + const laneTenants = Array.from({ length: 12 }, (_, i) => ({ + tenantKey: `tenant-${i}`, + queuedCount: i, + queuedCost: i * 10, + })); + mockHealthSources({ + health: { + uptime: 1, + version: "x", + adaptiveAdmission: { virtualLanes: true, laneTenants }, + }, + resilience: {}, + rateLimits: {}, + }); + + const result = await client.callTool({ name: "omniroute_get_health", arguments: {} }); + + const content = result.content as Array<{ type: string; text: string }>; + const data = JSON.parse(content[0].text); + expect(data.adaptiveAdmission.laneTenants).toHaveLength(10); + // Highest queued cost first, lowest dropped from the cap. + expect(data.adaptiveAdmission.laneTenants[0].tenantKey).toBe("tenant-11"); + expect(data.adaptiveAdmission.laneTenants[9].tenantKey).toBe("tenant-2"); + }); + + it("should omit adaptiveAdmission entirely when the health payload has none", async () => { + mockHealthSources({ + health: { uptime: 1, version: "x" }, + resilience: { circuitBreakers: [] }, + rateLimits: { limits: [] }, + }); + + const result = await client.callTool({ name: "omniroute_get_health", arguments: {} }); + + const content = result.content as Array<{ type: string; text: string }>; + const data = JSON.parse(content[0].text); + expect(data).not.toHaveProperty("adaptiveAdmission"); + }); }); diff --git a/open-sse/mcp-server/__tests__/toolSearch.catalog.test.ts b/open-sse/mcp-server/__tests__/toolSearch.catalog.test.ts index b5a982e548..fc8878bb75 100644 --- a/open-sse/mcp-server/__tests__/toolSearch.catalog.test.ts +++ b/open-sse/mcp-server/__tests__/toolSearch.catalog.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vitest"; import { getAllToolDefinitions } from "../toolSearch/catalog.ts"; +const GITHUB_SKILL_TOOL_NAMES = [ + "omniroute_github_skills_search", + "omniroute_github_skills_scan", + "omniroute_github_skills_install", +] as const; + describe("getAllToolDefinitions", () => { const all = getAllToolDefinitions(); it("aggregates many tools across collections", () => { @@ -18,6 +24,12 @@ describe("getAllToolDefinitions", () => { const names = all.map((t) => t.name); expect(new Set(names).size).toBe(names.length); }); + it("includes all GitHub skill tools", () => { + const names = new Set(all.map((tool) => tool.name)); + for (const name of GITHUB_SKILL_TOOL_NAMES) { + expect(names.has(name)).toBe(true); + } + }); it("includes every canonical CCR lifecycle tool", () => { for (const name of ["store", "retrieve", "inspect", "list", "delete", "stats"]) { expect(all.find((tool) => tool.name === `omniroute_ccr_${name}`)).toBeTruthy(); diff --git a/open-sse/mcp-server/__tests__/toolSearch.tool.test.ts b/open-sse/mcp-server/__tests__/toolSearch.tool.test.ts index 2c74a22635..6425b8bd22 100644 --- a/open-sse/mcp-server/__tests__/toolSearch.tool.test.ts +++ b/open-sse/mcp-server/__tests__/toolSearch.tool.test.ts @@ -29,11 +29,28 @@ describe("omniroute_tool_search", () => { }); it("returns relevant tool with a signature, not itself", async () => { - const res = await client.callTool({ name: "omniroute_tool_search", arguments: { query: "health" } }); + const res = await client.callTool({ + name: "omniroute_tool_search", + arguments: { query: "health" }, + }); const text = (res.content as Array<{ text: string }>)[0].text; const parsed = JSON.parse(text); expect(parsed.tools.some((t: any) => t.name === "omniroute_get_health")).toBe(true); expect(parsed.tools.every((t: any) => t.name !== "omniroute_tool_search")).toBe(true); expect(typeof parsed.tools[0].signature).toBe("string"); }); + + it("discovers all GitHub skill tools", async () => { + const res = await client.callTool({ + name: "omniroute_tool_search", + arguments: { query: "GitHub skills", limit: 25 }, + }); + const text = (res.content as Array<{ text: string }>)[0].text; + const parsed = JSON.parse(text); + const names = new Set(parsed.tools.map((tool: { name: string }) => tool.name)); + + expect(names.has("omniroute_github_skills_search")).toBe(true); + expect(names.has("omniroute_github_skills_scan")).toBe(true); + expect(names.has("omniroute_github_skills_install")).toBe(true); + }); }); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index d0d1c634cc..516b866ec3 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -68,6 +68,27 @@ export const getHealthOutput = z.object({ provider: z.string(), }) .optional(), + adaptiveAdmission: z + .object({ + virtualLanes: z.boolean(), + pressure: z.string(), + utilization: z.number(), + laneCount: z.number(), + laneQueuedCount: z.number(), + laneQueuedCost: z.number(), + laneTenants: z.array( + z.object({ + tenantKey: z.string(), + queuedCount: z.number(), + queuedCost: z.number(), + }) + ), + admittedCount: z.number(), + rejectedCount: z.number(), + wouldRejectCount: z.number(), + shutdown: z.boolean(), + }) + .optional(), degraded: z .array( z.object({ @@ -81,7 +102,7 @@ export const getHealthOutput = z.object({ export const getHealthTool: McpToolDefinition = { name: "omniroute_get_health", description: - "Returns the current health status of OmniRoute including uptime, memory usage, circuit breaker states for all providers, rate limit status, and cache statistics. If an underlying source (health/resilience/rate-limits) could not be reached, it is listed in `degraded` instead of being silently reported as empty/zero.", + "Returns the current health status of OmniRoute including uptime, memory usage, circuit breaker states for all providers, rate limit status, and cache statistics. When adaptive virtual-lane admission is active, a curated `adaptiveAdmission` block reports per-lane queue pressure (top tenants by queued cost). If an underlying source (health/resilience/rate-limits) could not be reached, it is listed in `degraded` instead of being silently reported as empty/zero.", inputSchema: getHealthInput, outputSchema: getHealthOutput, scopes: ["read:health"], diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index e5c65f8c62..f5634816e1 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -164,6 +164,12 @@ function toNumber(value: unknown, fallback = 0): number { return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +// Mirrors the runtime's env convention for lane flags ("1" | "true" are on) so a +// future string serialization can never silently invert a boolean lane report. +function isLaneFlagOn(value: unknown): boolean { + return value === true || value === "1" || value === "true"; +} + function toStringArray(value: unknown, fallback: string[] = []): string[] { const values = toArray(value).filter((entry): entry is string => typeof entry === "string"); return values.length > 0 ? values : fallback; @@ -292,6 +298,20 @@ async function handleGetHealth() { const cacheStatsRaw = toRecord(health.cacheStats); const resilienceCircuitBreakers = toArray(resilience.circuitBreakers); const rateLimitEntries = toArray(rateLimits.limits); + const adaptiveAdmissionRaw = toRecord(health.adaptiveAdmission); + // Curated lane subset: top lanes by queued cost so a congested tenant is + // visible first without shipping the whole admission snapshot to agents. + const laneTenants = toArray(adaptiveAdmissionRaw.laneTenants) + .map((tenant) => { + const record = toRecord(tenant); + return { + tenantKey: toString(record.tenantKey), + queuedCount: toNumber(record.queuedCount, 0), + queuedCost: toNumber(record.queuedCost, 0), + }; + }) + .sort((a, b) => b.queuedCost - a.queuedCost) + .slice(0, 10); // Surface fetch failures instead of letting Promise.allSettled's {} fallback // masquerade as genuine zero/empty data (indistinguishable "no data" vs. @@ -333,6 +353,22 @@ async function handleGetHealth() { provider: toString(toRecord(health.cryptography).provider, "unknown"), } : undefined, + adaptiveAdmission: + Object.keys(adaptiveAdmissionRaw).length > 0 + ? { + virtualLanes: isLaneFlagOn(adaptiveAdmissionRaw.virtualLanes), + pressure: toString(adaptiveAdmissionRaw.pressure), + utilization: toNumber(adaptiveAdmissionRaw.utilization, 0), + laneCount: toNumber(adaptiveAdmissionRaw.laneCount, 0), + laneQueuedCount: toNumber(adaptiveAdmissionRaw.laneQueuedCount, 0), + laneQueuedCost: toNumber(adaptiveAdmissionRaw.laneQueuedCost, 0), + laneTenants, + admittedCount: toNumber(adaptiveAdmissionRaw.admittedCount, 0), + rejectedCount: toNumber(adaptiveAdmissionRaw.rejectedCount, 0), + wouldRejectCount: toNumber(adaptiveAdmissionRaw.wouldRejectCount, 0), + shutdown: isLaneFlagOn(adaptiveAdmissionRaw.shutdown), + } + : undefined, degraded: degraded.length > 0 ? degraded : undefined, }; diff --git a/open-sse/mcp-server/toolSearch/catalog.ts b/open-sse/mcp-server/toolSearch/catalog.ts index df39f0d9ae..c91c5a272c 100644 --- a/open-sse/mcp-server/toolSearch/catalog.ts +++ b/open-sse/mcp-server/toolSearch/catalog.ts @@ -2,8 +2,9 @@ * getAllToolDefinitions — unified catalog of all MCP tool definitions. * * Aggregates the same collections referenced by TOTAL_MCP_TOOL_COUNT in server.ts: - * MCP_TOOLS + memoryTools + skillTools + agentSkillTools + poolTools + - * gamificationTools + pluginTools + notionTools + obsidianTools + * MCP_TOOLS + memoryTools + skillTools + agentSkillTools + githubSkillTools + + * poolTools + gamificationTools + pluginTools + notionTools + obsidianTools + + * localCorpusTools + compressionTools * * Tolerates both Array and Record shapes. Deduplicates by name (first wins). */ @@ -12,6 +13,7 @@ import { MCP_TOOLS } from "../schemas/tools.ts"; import { memoryTools } from "../tools/memoryTools.ts"; import { skillTools } from "../tools/skillTools.ts"; import { agentSkillTools } from "../tools/agentSkillTools.ts"; +import { githubSkillTools } from "../tools/githubSkillTools.ts"; import { poolTools } from "../tools/poolTools.ts"; import { gamificationTools } from "../tools/gamificationTools.ts"; import { pluginTools } from "../tools/pluginTools.ts"; @@ -72,6 +74,7 @@ export function getAllToolDefinitions(): ToolCatalogEntry[] { memoryTools, skillTools, agentSkillTools, + githubSkillTools, poolTools, gamificationTools, pluginTools, diff --git a/open-sse/services/__tests__/tierResolver.test.ts b/open-sse/services/__tests__/tierResolver.test.ts index 62ad4f1867..fac0b24404 100644 --- a/open-sse/services/__tests__/tierResolver.test.ts +++ b/open-sse/services/__tests__/tierResolver.test.ts @@ -239,13 +239,10 @@ describe("TierResolver", () => { it("deriveNoAuthFreeProviders includes all chat-tier noAuth providers", () => { const derived = deriveNoAuthFreeProviders(); - // opencode + mimocode are the ones the bug report called out + // opencode is one of the no-auth providers the bug report called out expect(derived.includes("opencode"), "opencode should be in derived noAuth-free list").toBe( true ); - expect(derived.includes("mimocode"), "mimocode should be in derived noAuth-free list").toBe( - true - ); expect(derived.includes("duckduckgo-web")).toBe(true); }); @@ -271,12 +268,6 @@ describe("TierResolver", () => { expect(result.hasFreeTier).toBe(true); }); - it("classifyTier classifies mimocode/mimo-auto as free via noAuth derivation", () => { - const result = classifyTier("mimocode", "mimo-auto"); - expect(result.tier).toBe(PROVIDER_TIER.FREE); - expect(result.hasFreeTier).toBe(true); - }); - it("classifyTier still returns cheap for paid glm-5.1 (no regression)", () => { // glm-5.1 is not in freeProviders, costs $0.50/M → cheap tier. // Make sure the new noAuth derivation didn't accidentally pull it into free. diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 2cf9105121..d63c58e94c 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -302,7 +302,7 @@ export const MODEL_ACCESS_DENIED_PATTERNS = [ // across every target, masking the real "fix your credential" error. When the // text clearly indicates a bad credential, the regex-based model-access detection // is suppressed (structured codes/types like model_not_found are unaffected). -const AUTH_CREDENTIAL_ERROR_PATTERNS = [ +export const AUTH_CREDENTIAL_ERROR_PATTERNS = [ /\b(?:invalid|incorrect|expired|missing|revoked)\s+api[\s_-]?key\b/i, /\bapi[\s_-]?key\s+(?:is\s+)?(?:invalid|incorrect|expired|missing|revoked|not\s+valid)\b/i, /\bauthentication\s+(?:failed|error|required)\b/i, @@ -311,6 +311,45 @@ const AUTH_CREDENTIAL_ERROR_PATTERNS = [ /\bnot\s+authenticated\b/i, ]; +// #10460: strict subset of MODEL_ACCESS_DENIED_PATTERNS that is unambiguously +// PROVIDER-wide — the model does not exist / is not served by this provider at all, so +// no account of that provider could serve it (e.g. "The requested model is not +// supported", "model not found"). Deliberately EXCLUDES the "access"/"permission" +// patterns from MODEL_ACCESS_DENIED_PATTERNS (e.g. "does not have permission to access +// this model", "access denied ... model"): those commonly indicate an ACCOUNT-scoped +// entitlement gap (e.g. PRO vs free tier) where a *different* account of the same +// provider may still have access, so they must keep rotating through the normal +// account-cooldown path — not be treated as provider-wide unsupported. +const PROVIDER_MODEL_UNSUPPORTED_PATTERNS = [ + /\binvalid model\b/i, + /\bmodel.*not.*(?:available|found|supported|accessible)\b/i, + /\bmodel.*(?:does not exist|doesn't exist)\b/i, + /\bmodel\b[\s\S]{0,80}?\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b/i, + /\b(?:does\s+not\s+support|doesn't\s+support|unsupported)\b[\s\S]{0,80}?\bmodel\b/i, + /\bunsupported\s+model\b/i, + /\bplease select a different model\b/i, +]; + +/** + * #10460: is this 400 an unambiguous, PROVIDER-wide "model not supported" response — + * i.e. would retrying a *different account* of the same provider also fail for the + * same reason? Reuses AUTH_CREDENTIAL_ERROR_PATTERNS (the same bad-credential + * exclusion `checkFallbackError`'s 400 branch applies) so a message like "invalid api + * key for model X" is never misclassified as model-wide. Also excludes the broader, + * ambiguous MODEL_ACCESS_DENIED_PATTERNS access/permission phrasing — those can be + * account-scoped entitlement gaps, not a provider-wide unsupported model — so account + * rotation for those keeps working normally via the regular cooldown path. + * + * Callers that want "should combo keep trying other targets" (not "should this + * specific account keep rotating") should use MODEL_ACCESS_DENIED_PATTERNS / + * isModelScoped400() instead — this helper is deliberately narrower. + */ +export function isProviderModelUnsupported400(status: number, errorText: string): boolean { + if (status !== HTTP_STATUS.BAD_REQUEST) return false; + if (AUTH_CREDENTIAL_ERROR_PATTERNS.some((p) => p.test(errorText))) return false; + return PROVIDER_MODEL_UNSUPPORTED_PATTERNS.some((p) => p.test(errorText)); +} + // Malformed request patterns — the model rejected the message format but a different // provider/model in the combo may accept it. const MALFORMED_REQUEST_PATTERNS = [ diff --git a/open-sse/services/admission/adaptation.ts b/open-sse/services/admission/adaptation.ts index c266c4b8f6..992c6f4b06 100644 --- a/open-sse/services/admission/adaptation.ts +++ b/open-sse/services/admission/adaptation.ts @@ -17,6 +17,13 @@ export interface AdaptationParams { export interface AdaptationState { currentLimit: number; + /** + * Idle-recovery target: the healthy starting aggregate budget (initialLimit). + * Used to climb the limit back up when a latency-gradient decrease has collapsed it + * below serviceable requests but the system is otherwise idle (#10111). Never grows + * beyond the configured maxLimit. + */ + recoveryCeiling: number; shortLatencyEwma: number; longLatencyEwma: number; pressure: AdmissionPressure; @@ -47,6 +54,7 @@ export function createAdaptationState( ): AdaptationState { return { currentLimit: clampLimit(initialLimit, minLimit, maxLimit), + recoveryCeiling: clampLimit(initialLimit, minLimit, maxLimit), shortLatencyEwma: 0, longLatencyEwma: 0, pressure: "normal", @@ -138,6 +146,14 @@ export function closeAdaptationWindow( // A genuinely low-utilization window recovers the latency baseline so stale gradients expire. if (state.utilization <= params.lowUtilizationThreshold) { state.shortLatencyEwma = state.longLatencyEwma; + // #10111 idle recovery (extracted helper): a latency-gradient decrease must not + // permanently lock the aggregate budget below serviceable requests. On a window with no + // completed work and low utilization (system idle), actively raise the limit back toward + // the recovery ceiling so ordinary requests can re-enter. The high-utilization/completed + // work increase branch above handles growth under load; this covers the no-progress + // starvation case. A window that completed a request (windowCompleted > 0) is the one + // whose latency samples triggered a decrease, so the two branches never fight. + next = applyIdleRecovery(state, params, next); } state.currentLimit = clampLimit(next, params.minLimit, params.maxLimit); @@ -150,6 +166,25 @@ export function closeAdaptationWindow( state.pressure = "normal"; } +/** + * #10111 idle-recovery helper. When a latency-gradient decrease has collapsed the aggregate + * limit below serviceable requests and the system is idle (no completed work, low + * utilization, normal non-critical pressure), raise the limit back toward the recovery + * ceiling by one bounded step so ordinary requests can re-enter. + */ +function applyIdleRecovery(state: AdaptationState, params: AdaptationParams, next: number): number { + if ( + state.pressure !== "critical" && + !state.freezeGrowth && + state.windowCompleted === 0 && + state.currentLimit < state.recoveryCeiling + ) { + const step = Math.min(params.increaseStep, params.maxIncreasePerWindow); + return Math.min(state.recoveryCeiling, next + step); + } + return next; +} + export function sampleActiveIntegral( state: AdaptationState, activeCost: number, diff --git a/open-sse/services/admission/controller.ts b/open-sse/services/admission/controller.ts index 8a6db9be74..ee2805ca81 100644 --- a/open-sse/services/admission/controller.ts +++ b/open-sse/services/admission/controller.ts @@ -1,4 +1,5 @@ import { + clampLimit, closeAdaptationWindow, createAdaptationState, noteLatency, @@ -28,10 +29,10 @@ import { } from "./types.ts"; /** - * Idle TTL for per-connection virtual admission lanes (#9654). + * Idle TTL for per-tenant virtual admission lanes (#9654). */ const ADMISSION_LANE_TTL_MS = 60_000; -/** Bounded per-connection lane map to prevent unbounded memory growth (#9654). */ +/** Bounded per-tenant lane map to prevent unbounded memory growth (#9654). */ const ADMISSION_LANE_MAX_SESSIONS = 1_000; type VirtualDisposition = "active" | "queued" | "rejected" | "none"; @@ -102,11 +103,14 @@ export class AdaptiveAdmissionController { private adaptation: AdaptationState; private queue: FairCostQueue; private virtualQueue: FairCostQueue<{ recordId: string }>; - /** Per-connection virtual admission lanes (#9654). */ - private readonly virtualLanes = new Map; - lastUsedMs: number; - }>(); + /** Per-tenant virtual admission lanes (#9654). */ + private readonly virtualLanes = new Map< + string, + { + queue: FairCostQueue; + lastUsedMs: number; + } + >(); /** Eviction timer for idle lanes; re-armed when a lane is created. */ private laneEvictionTimer: unknown = undefined; private readonly active = new Map(); @@ -151,6 +155,11 @@ export class AdaptiveAdmissionController { next.maxLimit, Math.max(next.minLimit, this.adaptation.currentLimit) ); + // #10111: the idle-recovery ceiling must track a new initialLimit (and the + // possibly-also-new min/maxLimit) instead of staying pinned to the value computed + // at construction time — otherwise a raised initialLimit can never recover past the + // stale ceiling, and a lowered one leaves the ceiling above the new maxLimit. + this.adaptation.recoveryCeiling = clampLimit(next.initialLimit, next.minLimit, next.maxLimit); this.adaptation.windowStartMs = this.clock.now(); this.adaptation.windowActiveCostIntegral = 0; this.adaptation.windowCompleted = 0; @@ -162,7 +171,7 @@ export class AdaptiveAdmissionController { const drained = this.queue.drain(); this.queue = new FairCostQueue(next.maxQueueCount, next.maxQueueCost); - // Drain per-connection virtual lane queues (#9654). + // Drain per-tenant virtual lane queues (#9654). for (const [, lane] of this.virtualLanes) { for (const entry of lane.queue.drain()) { drained.push(entry); @@ -213,6 +222,7 @@ export class AdaptiveAdmissionController { virtualActiveCount: saturateSnapshotNumber(this.virtualActiveCount), virtualQueuedCost: saturateSnapshotNumber(this.virtualQueue.totalCost), virtualQueuedCount: saturateSnapshotNumber(this.virtualQueue.size), + virtualLanes: this.config.virtualLanes === true, laneCount: saturateSnapshotNumber(this.virtualLanes.size), laneQueuedCost: saturateSnapshotNumber(this.laneTotalQueuedCost()), laneQueuedCount: saturateSnapshotNumber(this.laneTotalQueuedCount()), @@ -283,6 +293,17 @@ export class AdaptiveAdmissionController { // enforce if (cost > limit) { + // #10111 solo-progress: the adaptive aggregate limit can collapse below an + // individually-valid request (a slow-provider turn shrinks currentLimit via the + // latency gradient, and no increase can fire because every path to "completed" + // requires an admission). A request within the healthy aggregate ceiling must never + // be terminally rejected as oversized while the system is otherwise idle — admit a + // single bounded solo request so the pipeline keeps making progress and the limit can + // recover. The hard per-request ceiling (maxLimit), the critical/high pressure fuse, + // and a busy system (active/queued work present) all take precedence over solo. + if (this.shouldAdmitSolo(cost)) { + return this.admit(cost); + } return this.reject("ADMISSION_OVERSIZED", "request cost exceeds max budget"); } @@ -316,7 +337,7 @@ export class AdaptiveAdmissionController { ); this.rejectedCount += 1; } - // Drain per-connection virtual lane queues (#9654). + // Drain per-tenant virtual lane queues (#9654). for (const [, lane] of this.virtualLanes) { for (const entry of lane.queue.drain()) { this.clearEntryTimer(entry); @@ -331,6 +352,24 @@ export class AdaptiveAdmissionController { this.clearLaneEviction(); } + /** + * #10111: whether a request that currently exceeds the temporary aggregate limit may run + * solo. True only when the request fits the healthy aggregate ceiling (maxLimit), the + * system is otherwise idle (no active/queued/lane work) and pressure is normal — so an + * individually-valid request is not terminally rejected as oversized just because a + * latency-gradient decrease collapsed the temporary limit. Under genuine load, critical + * pressure, or an over-ceiling request the caller falls through to the terminal reject. + */ + private shouldAdmitSolo(cost: number): boolean { + return ( + cost <= this.config.maxLimit && + this.active.size === 0 && + this.queue.size === 0 && + this.laneTotalQueuedCount() === 0 && + this.adaptation.pressure === "normal" + ); + } + private resolveCost(request: AdmissionRequest): number { if (request.cost !== undefined) { return normalizeRequestCost(request.cost, this.config.maxRequestCost); @@ -480,9 +519,9 @@ export class AdaptiveAdmissionController { }, }; - // Per-connection virtual admission lanes (#9654): when enabled via + // Per-tenant virtual admission lanes (#9654): when enabled via // OMNIROUTE_CHAT_VIRTUAL_LANES=1, requests with a tenantKey are enqueued into - // a per-session lane queue instead of the shared queue, so one connection's + // a per-tenant lane queue instead of the shared queue, so one tenant's // burst does not 503 other sessions. Lanes are bounded by // ADMISSION_LANE_MAX_SESSIONS and idle-evicted after ADMISSION_LANE_TTL_MS. // Default: OFF — preserves the shared FairCostQueue round-robin behavior. @@ -522,7 +561,7 @@ export class AdaptiveAdmissionController { private expireEntry(id: string, code: AdmissionRejectCode, message: string): void { let entry = this.queue.removeById(id); if (!entry) { - // Search per-connection lane queues (#9654). + // Search per-tenant lane queues (#9654). for (const [, lane] of this.virtualLanes) { entry = lane.queue.removeById(id); if (entry) { @@ -581,7 +620,7 @@ export class AdaptiveAdmissionController { this.dispatchLanes(); } - /** Round-robin dispatch across per-connection virtual lane queues (#9654). */ + /** Round-robin dispatch across per-tenant virtual lane queues (#9654). */ private dispatchLanes(): void { if (this.shutDown || this.config.mode !== "enforce") return; if (this.virtualLanes.size === 0) return; @@ -621,7 +660,10 @@ export class AdaptiveAdmissionController { } } - private getOrCreateLane(tenantKey: string): { queue: FairCostQueue; lastUsedMs: number } { + private getOrCreateLane(tenantKey: string): { + queue: FairCostQueue; + lastUsedMs: number; + } { let lane = this.virtualLanes.get(tenantKey); if (!lane) { // Evict oldest lane if at capacity (LRU). @@ -727,7 +769,11 @@ export class AdaptiveAdmissionController { return count; } - private laneTenantSnapshot(): ReadonlyArray<{ tenantKey: string; queuedCount: number; queuedCost: number }> { + private laneTenantSnapshot(): ReadonlyArray<{ + tenantKey: string; + queuedCount: number; + queuedCost: number; + }> { const arr: { tenantKey: string; queuedCount: number; queuedCost: number }[] = []; for (const [tenantKey, lane] of this.virtualLanes) { arr.push({ diff --git a/open-sse/services/admission/index.ts b/open-sse/services/admission/index.ts index 48c3a5ad47..ea5a5f55f6 100644 --- a/open-sse/services/admission/index.ts +++ b/open-sse/services/admission/index.ts @@ -33,5 +33,6 @@ export { type AdmissionReleaseOutcome, type AdmissionRequest, type AdmissionSnapshot, + type PerTargetAdmissionHook, type ShadowDecision, } from "./types.ts"; diff --git a/open-sse/services/admission/runtime.ts b/open-sse/services/admission/runtime.ts index 3d7af5d48f..919509ce60 100644 --- a/open-sse/services/admission/runtime.ts +++ b/open-sse/services/admission/runtime.ts @@ -119,7 +119,7 @@ export function resolveAdaptiveAdmissionConfigFromEnv( // Shared pure validation — accept exact documented maxima, reject core-invalid configs. validateConfig(cfg); - // Per-connection virtual admission lanes (#9654) — opt-in via OMNIROUTE_CHAT_VIRTUAL_LANES. + // Per-tenant virtual admission lanes (#9654) — opt-in via OMNIROUTE_CHAT_VIRTUAL_LANES. const vlRaw = env.OMNIROUTE_CHAT_VIRTUAL_LANES; cfg.virtualLanes = vlRaw === "1" || vlRaw === "true"; diff --git a/open-sse/services/admission/types.ts b/open-sse/services/admission/types.ts index 93a782321d..5b540e14b5 100644 --- a/open-sse/services/admission/types.ts +++ b/open-sse/services/admission/types.ts @@ -80,7 +80,7 @@ export interface AdaptiveAdmissionConfig { maxIncreasePerWindow?: number; /** Optional cost quanta override used only when callers pass features instead of cost. */ cost?: Partial; - /** Per-connection virtual admission lanes (#9654). Default: false. */ + /** Per-tenant virtual admission lanes (#9654). Default: false. */ virtualLanes?: boolean; } @@ -95,6 +95,21 @@ export interface AdmissionRequest { pressure?: AdmissionPressure; } +/** + * #9654 Wave 2: per-target fan-out admission probe used by combo / fusion + * dispatchers. Returns true when the target may be dispatched, false when its + * tenant's virtual lane is full and the target should be skipped. + * + * Contract: strictly non-blocking (maxWaitMs 0 — skip, never queue), a no-op + * when virtual lanes are off (the parent request already holds the shared-queue + * lease), and keyed to the parent's tenantKey so it gates the same lane. + */ +export type PerTargetAdmissionHook = (target: { + modelStr: string; + executionKey: string; + body: unknown; +}) => Promise; + export interface AdmissionReleaseMeta { latencyMs?: number; pressure?: AdmissionPressure; @@ -140,7 +155,9 @@ export interface AdmissionSnapshot { virtualActiveCount: number; virtualQueuedCost: number; virtualQueuedCount: number; - /** Per-connection virtual lane metrics (#9654). */ + /** True when per-tenant virtual lanes are enabled (#9654). */ + virtualLanes: boolean; + /** Per-tenant virtual lane metrics (#9654). */ laneCount: number; laneQueuedCost: number; laneQueuedCount: number; diff --git a/open-sse/services/adobeFireflyChromeRuntime.ts b/open-sse/services/adobeFireflyChromeRuntime.ts deleted file mode 100644 index 5bd7638747..0000000000 --- a/open-sse/services/adobeFireflyChromeRuntime.ts +++ /dev/null @@ -1,1200 +0,0 @@ -/** - * Adobe Firefly optional Chrome (CDP) session runtime. - * - * Default product path is the same as other OmniRoute web-cookie providers - * (notion-web, perplexity-web, …): pure HTTP with the pasted Cookie/JWT — NO browser. - * - * Browser warm is OPT-IN for proactive use (`ADOBE_FIREFLY_BROWSER_REFRESH=1`) and may - * also run mid-batch 408 recovery via `allowWithoutEnvOptIn`. - * - * **Mode (UI + colligo):** background warm defaults to **offscreen headed** (parked off - * display + minimized) so Forter tokens work. True `--headless=new` is opt-in only - * (`ADOBE_FIREFLY_CHROME_HEADLESS=1`) and typically yields generate HTTP 408 while a real - * browser still works. Interactive sign-in uses modeOverride=visible. - */ - -import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { - buildAdobeArpSessionIdFromCookies, - extractAdobeForterTimestampMs, - mergeAdobeCookieHeaders, - type AdobeFireflySession, -} from "./adobeFireflySession.ts"; -import { - extractAdobeCookieHeader, - isAdobeUserAccessToken, - looksLikeAdobeJwt, - decodeAdobeJwtPayload, -} from "./adobeFireflyClient.ts"; - -const DEFAULT_CDP_PORT = Number(process.env.ADOBE_FIREFLY_CHROME_CDP_PORT || 9334); -const PROFILE_DIR_NAME = "adobe-chrome-profile"; - -type Log = { info?: (...a: unknown[]) => void; warn?: (...a: unknown[]) => void }; - -type RuntimeState = { - port: number; - profileDir: string; - chromeProc: ChildProcess | null; - browser: import("playwright").Browser | null; - context: import("playwright").BrowserContext | null; - page: import("playwright").Page | null; - lastWarmAt: number; - lastCookieSeed: string; - /** "offscreen" | "visible" | "headless" */ - mode: string; -}; - -let runtime: RuntimeState | null = null; -let warmChain: Promise = Promise.resolve(); -let startingChrome: Promise | null = null; -/** Temporary mode override (e.g. force a visible window for interactive sign-in). */ -let modeOverride: "offscreen" | "visible" | "headless" | null = null; - -/** - * Background cookie/JWT work should not flash a normal desktop window. - * - default / HEADED / OFFSCREEN → offscreen headed (Forter-safe; colligo accepts) - * - HEADLESS=1 → true headless (often 408 on generate — debug only) - * - VISIBLE=1 → on-screen (debug only; interactive sign-in uses modeOverride) - */ -function resolveChromeMode(): "offscreen" | "visible" | "headless" { - if (modeOverride) return modeOverride; - if (process.env.ADOBE_FIREFLY_CHROME_VISIBLE === "1") return "visible"; - // True headless is opt-in only — colligo rejects its Forter tokens (API 408, browser OK). - if (process.env.ADOBE_FIREFLY_CHROME_HEADLESS === "1") return "headless"; - return "offscreen"; -} - -async function safePageWait(page: import("playwright").Page, ms: number): Promise { - try { - if (page.isClosed()) return; - await page.waitForTimeout(ms); - } catch { - /* page closed / target destroyed — caller will re-acquire */ - } -} - -async function ensureLivePage( - context: import("playwright").BrowserContext, - preferred: import("playwright").Page | null -): Promise { - if (preferred && !preferred.isClosed()) { - try { - // Touch the page; if target is dead this throws - void preferred.url(); - return preferred; - } catch { - /* fall through */ - } - } - const existing = - context.pages().find((p) => !p.isClosed() && /firefly\.adobe\.com/i.test(p.url())) || - context.pages().find((p) => !p.isClosed()); - if (existing) return existing; - return context.newPage(); -} - -function dataDir(): string { - return ( - String(process.env.DATA_DIR || process.env.OMNIROUTE_DATA_DIR || "").trim() || - join(process.cwd(), ".data") - ); -} - -function profileDir(): string { - // Prefer LOCALAPPDATA when present so the managed Chrome profile survives restarts. - const local = process.env.LOCALAPPDATA || process.env.HOME || process.env.USERPROFILE || ""; - if (local) { - const p = join(local, "OmniRoute", PROFILE_DIR_NAME); - try { - mkdirSync(p, { recursive: true }); - } catch { - /* ignore */ - } - return p; - } - const p = join(dataDir(), PROFILE_DIR_NAME); - try { - mkdirSync(p, { recursive: true }); - } catch { - /* ignore */ - } - return p; -} - -function findChromeExecutable(): string | null { - if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) { - return process.env.CHROME_PATH; - } - const candidates = [ - "C:\\\\Program Files\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", - "C:\\\\Program Files (x86)\\\\Google\\\\Chrome\\\\Application\\\\chrome.exe", - join(process.env.LOCALAPPDATA || "", "Google", "Chrome", "Application", "chrome.exe"), - "/usr/bin/google-chrome", - "/usr/bin/chromium-browser", - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - ]; - for (const c of candidates) { - if (c && existsSync(c)) return c; - } - return null; -} - -async function waitForCdp(port: number, timeoutMs: number): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const r = await fetch(`http://127.0.0.1:${port}/json/version`); - if (r.ok) return; - } catch { - /* retry */ - } - await new Promise((r) => setTimeout(r, 350)); - } - throw new Error(`Chrome CDP not ready on port ${port}`); -} - -async function killPortOwner(port: number): Promise { - if (process.platform !== "win32") return; - try { - const { execSync } = await import("node:child_process"); - execSync( - `powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }"`, - { stdio: "ignore", timeout: 8000 } - ); - } catch { - /* ignore */ - } -} - -function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> { - const out: Array<{ name: string; value: string }> = []; - for (const part of String(cookieHeader || "").split(";")) { - const idx = part.indexOf("="); - if (idx <= 0) continue; - let name = part.slice(0, idx).trim(); - let value = part.slice(idx + 1).trim(); - try { - name = decodeURIComponent(name); - } catch { - /* keep */ - } - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - if (!name || /[\r\n\0]/.test(value)) continue; - out.push({ name, value }); - } - return out; -} - -/** Detect whether the process listening on `port` was started with --headless. */ -async function isPortChromeHeadless(port: number): Promise { - if (process.platform !== "win32") return null; - try { - const { execSync } = await import("node:child_process"); - const out = execSync( - `powershell -NoProfile -Command "$c=Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if(-not $c){exit 2}; $p=Get-CimInstance Win32_Process -Filter (\\"ProcessId=$($c.OwningProcess)\\"); if($p.CommandLine -match 'headless'){Write-Output 'headless'}else{Write-Output 'headed'}"`, - { encoding: "utf8", timeout: 8000, stdio: ["ignore", "pipe", "ignore"] } - ).trim(); - if (out === "headless") return true; - if (out === "headed") return false; - return null; - } catch { - return null; - } -} - -async function tryConnectExistingCdp( - chromium: typeof import("playwright").chromium, - port: number, - dir: string, - desiredMode: string, - log?: Log -): Promise { - try { - const r = await fetch(`http://127.0.0.1:${port}/json/version`); - if (!r.ok) return null; - - // Match process headless-ness to desiredMode: - // - headless desired: never reuse a headed process (would flash a real window). - // - offscreen/visible desired: never reuse headless (wrong Forter/profile mode). - const headless = await isPortChromeHeadless(port); - if (desiredMode === "headless" && headless === false) { - log?.warn?.( - "ADOBE-FIREFLY", - `existing CDP on ${port} is headed — killing and restarting as headless (no UI)` - ); - await killPortOwner(port); - return null; - } - if (desiredMode !== "headless" && headless === true) { - log?.warn?.( - "ADOBE-FIREFLY", - `existing CDP on ${port} is headless — killing and restarting as ${desiredMode}` - ); - await killPortOwner(port); - return null; - } - - const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); - const context = browser.contexts()[0] || (await browser.newContext()); - const page = await ensureLivePage(context, null); - log?.info?.( - "ADOBE-FIREFLY", - `reused existing Chrome CDP port=${port} desiredMode=${desiredMode} pages=${context.pages().length}` - ); - return { - port, - profileDir: dir, - chromeProc: null, - browser, - context, - page, - lastWarmAt: 0, - lastCookieSeed: "", - mode: desiredMode, - }; - } catch { - return null; - } -} - -/** - * Chrome remembers last window bounds in the profile. Off-screen warms park the window at - * ~(-32000,-32000) / secondary-monitor coords — a later "visible" sign-in then opens Firefly - * off-screen and the user sees nothing. Reset placement on disk before a visible spawn. - */ -function resetChromeWindowPlacementOnDisk(dir: string, log?: Log): void { - const candidates = [join(dir, "Default", "Preferences"), join(dir, "Preferences")]; - const onScreen = { - bottom: 960, - left: 80, - maximized: false, - right: 1360, - top: 60, - work_area_bottom: 1080, - work_area_left: 0, - work_area_right: 1920, - work_area_top: 0, - }; - for (const path of candidates) { - if (!existsSync(path)) continue; - try { - const raw = readFileSync(path, "utf8"); - const obj = JSON.parse(raw) as Record; - const browser = ( - obj.browser && typeof obj.browser === "object" - ? (obj.browser as Record) - : {} - ) as Record; - browser.window_placement = onScreen; - browser.window_placement_popup = onScreen; - obj.browser = browser; - // Avoid session restore putting us back off-screen. - if (obj.profile && typeof obj.profile === "object") { - (obj.profile as Record).exit_type = "Normal"; - (obj.profile as Record).exited_cleanly = true; - } - writeFileSync(path, JSON.stringify(obj), "utf8"); - log?.info?.("ADOBE-FIREFLY", `reset Chrome window_placement on disk (${path})`); - } catch (err) { - log?.warn?.( - "ADOBE-FIREFLY", - `could not reset window_placement: ${err instanceof Error ? err.message : String(err)}` - ); - } - } -} - -/** After CDP connect, force the browser window onto the primary work area (visible sign-in). */ -async function forceChromeWindowOnScreen( - browser: import("playwright").Browser, - page: import("playwright").Page, - log?: Log -): Promise { - try { - const cdp = await page.context().newCDPSession(page); - const { windowId } = (await cdp.send( - "Browser.getWindowForTarget" as "Browser.getWindowForTarget" - )) as { - windowId: number; - }; - await cdp.send("Browser.setWindowBounds" as "Browser.setWindowBounds", { - windowId, - bounds: { - left: 80, - top: 60, - width: 1280, - height: 900, - windowState: "normal", - }, - }); - await page.bringToFront().catch(() => {}); - // Best-effort Windows focus (Chrome can open behind the host app). - if (process.platform === "win32") { - try { - const { execSync } = await import("node:child_process"); - execSync( - `powershell -NoProfile -Command "$p=Get-Process chrome -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -match 'Firefly|Adobe|Chrome' } | Select-Object -First 1; if($p){ Add-Type -Name W -Namespace N -MemberDefinition '[DllImport(\\\"user32.dll\\\")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport(\\\"user32.dll\\\")] public static extern bool ShowWindow(IntPtr h,int n);'; [N.W]::ShowWindow($p.MainWindowHandle,9) | Out-Null; [N.W]::SetForegroundWindow($p.MainWindowHandle) | Out-Null }"`, - { stdio: "ignore", timeout: 5000 } - ); - } catch { - /* ignore */ - } - } - log?.info?.("ADOBE-FIREFLY", "forced Chrome window on-screen (80,60 1280x900)"); - } catch (err) { - log?.warn?.( - "ADOBE-FIREFLY", - `forceChromeWindowOnScreen failed: ${err instanceof Error ? err.message : String(err)}` - ); - } -} - -async function ensureChromeStarted( - log?: Log, - opts?: { forceRestart?: boolean } -): Promise { - const mode = resolveChromeMode(); - - // Always kill the CDP port on forceRestart (even if in-memory runtime is null — leftover - // off-screen Chrome from a prior warm is the usual "browser didn't appear" case). - if (opts?.forceRestart) { - try { - await runtime?.browser?.close(); - } catch { - /* ignore */ - } - runtime = null; - await killPortOwner(DEFAULT_CDP_PORT); - } - - if (runtime?.browser && runtime.context) { - // Mode mismatch: always restart so we never keep a headed UI when silent headless - // is required, and never keep headless when offscreen/visible is required. - if (runtime.mode !== mode) { - log?.warn?.( - "ADOBE-FIREFLY", - `cached Chrome mode=${runtime.mode} desired=${mode} — restarting` - ); - try { - await runtime.browser?.close(); - } catch { - /* ignore */ - } - runtime = null; - await killPortOwner(DEFAULT_CDP_PORT); - } else { - try { - await fetch(`http://127.0.0.1:${runtime.port}/json/version`); - // Live process must still match headless/headed expectation. - const hl = await isPortChromeHeadless(runtime.port); - const mismatch = - (mode === "headless" && hl === false) || (mode !== "headless" && hl === true); - if (mismatch) { - log?.warn?.("ADOBE-FIREFLY", `live CDP headless=${hl} desired=${mode} — restarting`); - try { - await runtime.browser?.close(); - } catch { - /* ignore */ - } - runtime = null; - await killPortOwner(DEFAULT_CDP_PORT); - } else { - runtime.page = await ensureLivePage(runtime.context, runtime.page); - return runtime; - } - } catch { - try { - await runtime?.browser?.close(); - } catch { - /* ignore */ - } - runtime = null; - } - } - } - - if (startingChrome) return startingChrome; - - if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") { - throw new Error("ADOBE_FIREFLY_BROWSER_REFRESH=0"); - } - - startingChrome = (async () => { - const chromePath = findChromeExecutable(); - if (!chromePath) throw new Error("Google Chrome not found (set CHROME_PATH)"); - - let chromium: typeof import("playwright").chromium; - try { - chromium = (await import("playwright")).chromium; - } catch { - throw new Error("playwright package not available for CDP connect"); - } - - const port = DEFAULT_CDP_PORT; - const dir = profileDir(); - - // Prefer reusing a healthy CDP only when mode matches (headless vs headed). - // Mismatched reuse is rejected inside tryConnectExistingCdp. - if (!opts?.forceRestart) { - const existing = await tryConnectExistingCdp(chromium, port, dir, mode, log); - if (existing) { - runtime = existing; - return existing; - } - } - - // Kill stale listener before spawn (headless leftover / force restart). - await killPortOwner(port); - - // Visible sign-in: wipe off-screen bounds left by prior off-screen warms. - if (mode === "visible") { - resetChromeWindowPlacementOnDisk(dir, log); - } - - // Default headless: zero UI for cookie/JWT warm. Offscreen/visible are opt-in only. - const args = [ - `--remote-debugging-port=${port}`, - "--remote-debugging-address=127.0.0.1", - "--remote-allow-origins=*", - `--user-data-dir=${dir}`, - "--no-first-run", - "--no-default-browser-check", - "--disable-blink-features=AutomationControlled", - "--disable-features=TranslateUI", - "--disable-session-crashed-bubble", - "--hide-crash-restore-bubble", - ...(mode === "headless" - ? ["--headless=new", "--disable-gpu", "--window-size=1280,900"] - : mode === "offscreen" - ? [ - "--window-position=-32000,-32000", - "--window-size=1280,900", - // Start minimized as extra belt-and-suspenders (Windows may still create a taskbar entry). - "--start-minimized", - ] - : [ - // Explicit on-screen position — profile restore alone is not enough. - "--window-position=80,60", - "--window-size=1280,900", - "--start-maximized", - ]), - mode === "visible" - ? "https://firefly.adobe.com/" - : "https://firefly.adobe.com/generate/image", - ]; - - log?.info?.( - "ADOBE-FIREFLY", - `starting Chrome CDP profile=${dir} port=${port} mode=${mode} (headless=silent; offscreen=headed parked; visible=on-screen sign-in)` - ); - const chromeProc = spawn(chromePath, args, { - stdio: "ignore", - detached: true, - // Only interactive sign-in may show a window host; silent refresh stays hidden. - windowsHide: mode !== "visible", - }); - chromeProc.unref(); - - await waitForCdp(port, 45_000); - const browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`); - const context = browser.contexts()[0] || (await browser.newContext()); - const page = await ensureLivePage(context, null); - - if (mode === "visible") { - await forceChromeWindowOnScreen(browser, page, log); - } - - runtime = { - port, - profileDir: dir, - chromeProc, - browser, - context, - page, - lastWarmAt: 0, - lastCookieSeed: "", - mode, - }; - return runtime; - })(); - - try { - return await startingChrome; - } finally { - startingChrome = null; - } -} - -async function seedCookies( - context: import("playwright").BrowserContext, - cookieHeader: string -): Promise { - const pairs = parseCookieHeader(cookieHeader); - let n = 0; - for (const { name, value } of pairs) { - for (const domain of [".adobe.com", "firefly.adobe.com", ".firefly.adobe.com"]) { - try { - await context.addCookies([ - { name, value, domain, path: "/", secure: true, sameSite: "Lax" }, - ]); - n++; - break; - } catch { - /* try next domain */ - } - } - } - return n; -} - -function extractUserJwtFromStorageRaw(raw: string): string { - const matches = - String(raw || "").match(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g) || []; - for (const tok of matches) { - if (looksLikeAdobeJwt(tok) && isAdobeUserAccessToken(tok)) return tok; - } - return ""; -} - -async function readSpaUserJwt(page: import("playwright").Page): Promise { - const tokens = await page.evaluate(() => { - const out: string[] = []; - for (const key of Object.keys(sessionStorage)) { - if (!/adobeid_ims_access_token|clio-playground/i.test(key)) continue; - out.push(sessionStorage.getItem(key) || ""); - } - return out; - }); - for (const raw of tokens) { - const tok = extractUserJwtFromStorageRaw(raw); - if (tok) return tok; - } - // broader scan - const all = await page.evaluate(() => { - const out: string[] = []; - for (const key of Object.keys(sessionStorage)) out.push(sessionStorage.getItem(key) || ""); - return out; - }); - for (const raw of all) { - const tok = extractUserJwtFromStorageRaw(raw); - if (tok) return tok; - } - return ""; -} - -async function injectUserJwt(page: import("playwright").Page, token: string): Promise { - if (!token) return; - await page - .evaluate((t) => { - for (const key of Object.keys(sessionStorage)) { - if (!key.includes("adobeid_ims_access_token")) continue; - try { - const obj = JSON.parse(sessionStorage.getItem(key) || "{}") as Record; - obj.tokenValue = t; - obj.access_token = t; - obj.valid = true; - obj.expire = Date.now() + 20 * 3600 * 1000; - obj.expires_in = 86400000; - obj.client_id = "clio-playground-web"; - sessionStorage.setItem(key, JSON.stringify(obj)); - } catch { - /* skip */ - } - } - }, token) - .catch(() => {}); -} - -async function humanize(page: import("playwright").Page): Promise { - try { - if (page.isClosed()) return; - for (let i = 0; i < 16; i++) { - if (page.isClosed()) return; - await page.mouse.move(100 + i * 45, 160 + (i % 5) * 35, { steps: 4 }); - await safePageWait(page, 80); - } - // Light scroll nudges Forter / passive listeners on real headed Chrome. - await page.mouse.wheel(0, 240).catch(() => {}); - await safePageWait(page, 200); - await page.mouse.wheel(0, -120).catch(() => {}); - } catch { - /* ignore */ - } -} - -/** Poll jar until forterToken timestamp advances past `minTs`, or timeout. */ -async function waitForFresherForter( - context: import("playwright").BrowserContext, - minTs: number, - timeoutMs: number, - log?: Log -): Promise { - const start = Date.now(); - let best = 0; - while (Date.now() - start < timeoutMs) { - const cookie = await jarCookieHeader(context); - const ts = extractAdobeForterTimestampMs(cookie); - if (ts > best) best = ts; - if (ts > minTs) { - log?.info?.("ADOBE-FIREFLY", `Chrome forter refreshed (ts=${ts}, deltaMs=${ts - minTs})`); - return ts; - } - await new Promise((r) => setTimeout(r, 1500)); - } - log?.warn?.( - "ADOBE-FIREFLY", - `Chrome forter did not advance past ${minTs} within ${timeoutMs}ms (best=${best})` - ); - return best; -} - -async function jarCookieHeader(context: import("playwright").BrowserContext): Promise { - const jar = await context.cookies(); - // Prefer firefly-relevant cookies; keep full jar for rebuild pieces - return jar.map((c) => `${c.name}=${c.value}`).join("; "); -} - -async function buildArpFromContext( - context: import("playwright").BrowserContext, - page: import("playwright").Page -): Promise<{ arp: string; cookie: string }> { - const cookie = await jarCookieHeader(context); - const ls = await page - .evaluate(() => ({ - bfp: localStorage.getItem("bfp") || "", - fpjs: localStorage.getItem("fpjs") || "", - })) - .catch(() => ({ bfp: "", fpjs: "" })); - let blob = cookie; - if (ls.bfp && !/(?:^|;\s*)bfp=/.test(blob)) blob = mergeAdobeCookieHeaders(blob, `bfp=${ls.bfp}`); - if (ls.fpjs && !/(?:^|;\s*)fpjs=/.test(blob)) { - blob = mergeAdobeCookieHeaders(blob, `fpjs=${encodeURIComponent(ls.fpjs)}`); - } - const arp = - buildAdobeArpSessionIdFromCookies(blob, { - bfp: ls.bfp || undefined, - fpjs: ls.fpjs || undefined, - }) || ""; - return { arp, cookie: extractAdobeCookieHeader(blob) || blob }; -} - -/** - * Warm (or create) the durable Chrome Firefly session. - * Returns accessToken + cookie + arpSessionId ready for generate-async. - */ -export async function warmAdobeFireflyViaChrome(opts: { - cookie: string; - accessToken?: string; - log?: Log; - /** Wait for interactive login if only guest JWT is present (ms, 0 = don't wait). */ - waitForLoginMs?: number; - /** - * Mid-batch 408 recovery: allow warm without ADOBE_FIREFLY_BROWSER_REFRESH=1. - * Uses headless Chrome by default (no UI). Opt into headed offscreen with - * ADOBE_FIREFLY_CHROME_HEADED=1 if diagnosing colligo. - */ - allowWithoutEnvOptIn?: boolean; - /** When true (or ADOBE_FIREFLY_CHROME_PING=1), prove ARP with in-page generate-async. */ - proveWithPing?: boolean; -}): Promise { - // Kill switch - if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") return null; - // Default OFF for proactive use; recovery may pass allowWithoutEnvOptIn. - if (!opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_BROWSER_REFRESH !== "1") return null; - if (process.env.NODE_ENV === "test" || process.env.VITEST || process.env.NODE_TEST_CONTEXT) { - return null; - } - - const run = warmChain.then(async () => { - const log = opts.log; - const cookieIn = extractAdobeCookieHeader(opts.cookie) || opts.cookie; - if (!cookieIn?.trim() && !opts.accessToken) return null; - - const forterBefore = extractAdobeForterTimestampMs(cookieIn); - // Force restart on recovery so we never reuse a half-dead CDP; mode is still headless - // by default (no popup). ADOBE_FIREFLY_CHROME_HEADED=1 opts into offscreen headed. - const rt = await ensureChromeStarted(log, { - forceRestart: - Boolean(opts.allowWithoutEnvOptIn) || - process.env.ADOBE_FIREFLY_CHROME_FORCE_RESTART === "1", - }); - const context = rt.context!; - let page = await ensureLivePage(context, rt.page); - - if (cookieIn && cookieIn !== rt.lastCookieSeed) { - const n = await seedCookies(context, cookieIn); - rt.lastCookieSeed = cookieIn; - log?.info?.("ADOBE-FIREFLY", `Chrome seeded ${n} cookie entries`); - } - - // Navigate / reload with page-closed recovery (prior flaky "Target page closed"). - const gotoFirefly = async () => { - page = await ensureLivePage(context, page); - if (!/firefly\.adobe\.com/i.test(page.url())) { - await page.goto("https://firefly.adobe.com/generate/image", { - waitUntil: "domcontentloaded", - timeout: 90_000, - }); - } else { - await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(async () => { - page = await ensureLivePage(context, null); - await page.goto("https://firefly.adobe.com/generate/image", { - waitUntil: "domcontentloaded", - timeout: 90_000, - }); - }); - } - }; - - await gotoFirefly(); - await safePageWait(page, 8_000); - await humanize(page); - - let jwt = await readSpaUserJwt(page).catch(() => ""); - if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) { - page = await ensureLivePage(context, page); - await injectUserJwt(page, opts.accessToken); - await page.reload({ waitUntil: "domcontentloaded", timeout: 90_000 }).catch(() => {}); - await safePageWait(page, 6_000); - await humanize(page); - jwt = (await readSpaUserJwt(page).catch(() => "")) || opts.accessToken; - log?.info?.("ADOBE-FIREFLY", "Chrome injected cached user JWT into SPA sessionStorage"); - } - - // Wait for interactive login if still no user JWT (one-time profile SSO) - const waitMs = opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 0); - if (!jwt && waitMs > 0) { - log?.warn?.( - "ADOBE-FIREFLY", - `No user JWT yet — sign in to Firefly in the Chrome window (wait ${Math.round(waitMs / 1000)}s)` - ); - const start = Date.now(); - while (Date.now() - start < waitMs) { - await safePageWait(page, 2000); - page = await ensureLivePage(context, page); - jwt = await readSpaUserJwt(page).catch(() => ""); - if (jwt) break; - } - } - - if (!jwt && opts.accessToken && isAdobeUserAccessToken(opts.accessToken)) { - jwt = opts.accessToken; - } - if (!jwt || !isAdobeUserAccessToken(jwt)) { - log?.warn?.("ADOBE-FIREFLY", "Chrome warm: still no AdobeID user JWT (cookie-only guest)"); - // Still return ARP if possible — caller may already have JWT - if (!opts.accessToken) return null; - jwt = opts.accessToken; - } - - // Give Forter SDK time to mint a NEW forterToken (stale paste is the usual 408 root cause). - const forterWaitMs = Number(process.env.ADOBE_FIREFLY_FORTER_WAIT_MS || 45_000); - await waitForFresherForter(context, forterBefore, forterWaitMs, log); - - // Second humanize + short settle after token land - page = await ensureLivePage(context, page); - await humanize(page); - await safePageWait(page, 2_000); - - let { arp, cookie } = await buildArpFromContext(context, page); - if (!arp) { - log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar — one more reload"); - await gotoFirefly(); - await safePageWait(page, 8_000); - await humanize(page); - await waitForFresherForter(context, forterBefore, 20_000, log); - ({ arp, cookie } = await buildArpFromContext(context, page)); - } - if (!arp) { - log?.warn?.("ADOBE-FIREFLY", "Chrome warm: could not rebuild ARP from jar"); - return null; - } - - // Prove colligo accepts this ARP. Default ON for recovery path; env can force either way. - const shouldPing = - opts.proveWithPing === true || - process.env.ADOBE_FIREFLY_CHROME_PING === "1" || - (opts.allowWithoutEnvOptIn && process.env.ADOBE_FIREFLY_CHROME_PING !== "0"); - if (shouldPing) { - page = await ensureLivePage(context, page); - const ok = await pingGenerateInPage(page, jwt, arp, log); - if (!ok) { - log?.warn?.( - "ADOBE-FIREFLY", - "Chrome ping generate failed — waiting for forter once more and rebuilding ARP" - ); - await waitForFresherForter(context, extractAdobeForterTimestampMs(cookie), 20_000, log); - ({ arp, cookie } = await buildArpFromContext(context, page)); - if (arp) { - page = await ensureLivePage(context, page); - const ok2 = await pingGenerateInPage(page, jwt, arp, log); - if (!ok2) { - log?.warn?.("ADOBE-FIREFLY", "Chrome ping still failed — returning ARP for node retry"); - } - } - } - } - - rt.page = page; - rt.lastWarmAt = Date.now(); - const ftrTs = extractAdobeForterTimestampMs(cookie); - log?.info?.( - "ADOBE-FIREFLY", - `Chrome warm OK (mode=${rt.mode}, arpLen=${arp.length}, forterTs=${ftrTs || 0}, forterDeltaMs=${ftrTs && forterBefore ? ftrTs - forterBefore : "n/a"}, user=${String(decodeAdobeJwtPayload(jwt)?.user_id || "").slice(0, 20)})` - ); - - return { - accessToken: jwt, - cookie, - arpSessionId: arp, - tokenExpiresAt: (() => { - const p = decodeAdobeJwtPayload(jwt); - const created = Number(p?.created_at || 0); - const exp = Number(p?.expires_in || 0); - return created && exp ? created + exp : Date.now() + 20 * 3600_000; - })(), - updatedAt: Date.now(), - fingerprint: "chrome", - source: "browser" as const, - }; - }); - - // Serialize warms - warmChain = run.then( - () => undefined, - () => undefined - ); - try { - return await run; - } catch (err) { - opts.log?.warn?.( - "ADOBE-FIREFLY", - `Chrome warm failed: ${err instanceof Error ? err.message : String(err)}` - ); - // Soft-reset page/browser handle but do not kill Chrome process — reuse next warm. - if (runtime) { - runtime.page = null; - try { - await runtime.browser?.close(); - } catch { - /* ignore */ - } - runtime.browser = null; - runtime.context = null; - } - runtime = null; - return null; - } -} - -/** - * Wipe Adobe SSO from the managed profile so "Add Account" can log into a *new* identity - * instead of silently reusing the previous Adobe session. - */ -async function clearAdobeBrowserSession( - context: import("playwright").BrowserContext, - page: import("playwright").Page, - log?: Log -): Promise { - try { - await context.clearCookies(); - } catch { - /* ignore */ - } - try { - await page.goto("https://firefly.adobe.com/", { - waitUntil: "domcontentloaded", - timeout: 60_000, - }); - await page - .evaluate(() => { - try { - sessionStorage.clear(); - } catch { - /* ignore */ - } - try { - localStorage.clear(); - } catch { - /* ignore */ - } - }) - .catch(() => {}); - } catch { - /* ignore */ - } - // Best-effort IMS logout so the next load shows the sign-in UI. - try { - await page.goto( - "https://auth.services.adobe.com/en_US/index.html?callback=https%3A%2F%2Ffirefly.adobe.com%2F", - { - waitUntil: "domcontentloaded", - timeout: 45_000, - } - ); - await safePageWait(page, 1500); - } catch { - /* ignore */ - } - log?.info?.("ADOBE-FIREFLY", "sign-in: cleared prior Adobe session for a fresh login"); -} - -/** - * Interactive one-time sign-in for the "browser session" credential model. - * Opens a VISIBLE managed Chrome (persistent profile), navigates to Firefly, and waits for the - * user to log in. Returns the IMS JWT + cookie jar so generate works immediately without - * depending on sessionStorage surviving a browser close. - * Never throws — returns { success:false } on timeout / unavailable. - */ -export async function loginAdobeFireflyViaChrome(opts: { - cookie?: string; - /** Max time to wait for the user to complete login (ms). Default 5 min. */ - waitForLoginMs?: number; - /** - * When true (default for "Add Account"), wipe the prior Adobe SSO so a *new* account can be - * signed in instead of reopening the previous logged-in profile. - */ - freshSession?: boolean; - log?: Log; -}): Promise<{ - success: boolean; - account?: string; - accessToken?: string; - cookie?: string; - arpSessionId?: string; -}> { - if (process.env.ADOBE_FIREFLY_BROWSER_REFRESH === "0") { - return { success: false }; - } - const log = opts.log; - const prev = modeOverride; - modeOverride = "visible"; - const fresh = opts.freshSession !== false; // default true for multi-account Add Account - try { - // Fresh visible window (a cached off-screen CDP would be parked off-display for login). - // forceRestart ALWAYS kills port 9334 + restarts with on-screen bounds. - const rt = await ensureChromeStarted(log, { forceRestart: true }); - const context = rt.context!; - let page = await ensureLivePage(context, rt.page); - - // Re-assert on-screen + foreground (profile may re-apply bad bounds after first paint). - await forceChromeWindowOnScreen(rt.browser!, page, log); - - if (fresh) { - await clearAdobeBrowserSession(context, page, log); - page = await ensureLivePage(context, null); - rt.lastCookieSeed = ""; - } else { - const cookieIn = opts.cookie ? extractAdobeCookieHeader(opts.cookie) || opts.cookie : ""; - if (cookieIn) { - const n = await seedCookies(context, cookieIn); - rt.lastCookieSeed = cookieIn; - log?.info?.("ADOBE-FIREFLY", `sign-in: seeded ${n} cookie entries as a hint`); - } - } - - await page - .goto("https://firefly.adobe.com/", { waitUntil: "domcontentloaded", timeout: 90_000 }) - .catch(() => {}); - page = await ensureLivePage(context, page); - await forceChromeWindowOnScreen(rt.browser!, page, log); - log?.info?.( - "ADOBE-FIREFLY", - `sign-in: Chrome window open ON-SCREEN (fresh=${fresh}) — waiting for Adobe login…` - ); - - const waitMs = - opts.waitForLoginMs ?? Number(process.env.ADOBE_FIREFLY_LOGIN_WAIT_MS || 300_000); - const start = Date.now(); - let jwt = ""; - while (Date.now() - start < waitMs) { - await safePageWait(page, 2500); - page = await ensureLivePage(context, page); - jwt = await readSpaUserJwt(page).catch(() => ""); - if (jwt && isAdobeUserAccessToken(jwt)) break; - } - const ok = Boolean(jwt && isAdobeUserAccessToken(jwt)); - const account = ok ? String(decodeAdobeJwtPayload(jwt)?.user_id || "") : undefined; - - // Capture durable credentials BEFORE closing the window (sessionStorage JWT dies with the tab). - let cookie = ""; - let arpSessionId = ""; - if (ok) { - try { - const built = await buildArpFromContext(context, page); - cookie = extractAdobeCookieHeader(built.cookie) || built.cookie || ""; - arpSessionId = built.arp || ""; - } catch { - cookie = (await jarCookieHeader(context).catch(() => "")) || ""; - } - } - - log?.info?.( - "ADOBE-FIREFLY", - ok - ? `sign-in OK (account=${account?.slice(0, 24)}, cookieLen=${cookie.length}, arpLen=${arpSessionId.length})` - : "sign-in timed out — no AdobeID session" - ); - - // Close the visible window; the persistent profile keeps the SSO for later headless warms. - try { - await rt.browser?.close(); - } catch { - /* ignore */ - } - runtime = null; - return { - success: ok, - account, - accessToken: ok ? jwt : undefined, - cookie: ok ? cookie : undefined, - arpSessionId: ok ? arpSessionId : undefined, - }; - } catch (err) { - log?.warn?.( - "ADOBE-FIREFLY", - `sign-in failed: ${err instanceof Error ? err.message : String(err)}` - ); - try { - await runtime?.browser?.close(); - } catch { - /* ignore */ - } - runtime = null; - return { success: false }; - } finally { - modeOverride = prev; - } -} - -async function pingGenerateInPage( - page: import("playwright").Page, - token: string, - arp: string, - log?: Log -): Promise { - try { - const res = await page.evaluate( - async ({ token, arp }) => { - const claims = JSON.parse( - atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) - ) as { user_id?: string }; - const prompt = "ping"; - const data = new TextEncoder().encode(String(claims.user_id || "") + "-" + prompt); - const hash = await crypto.subtle.digest("SHA-256", data); - const nonce = [...new Uint8Array(hash)] - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", { - method: "POST", - headers: { - Authorization: "Bearer " + token, - "x-api-key": "clio-playground-web", - "content-type": "application/json", - accept: "*/*", - "x-nonce": nonce, - "x-arp-session-id": arp, - }, - credentials: "include", - body: JSON.stringify({ - n: 1, - seeds: [1], - output: { storeInputs: true }, - prompt, - referenceBlobs: [], - modelSpecificPayload: { size: "auto" }, - modelId: "gpt-image", - modelVersion: "2", - generationMetadata: { module: "text2image", submodule: "ff-image-generate" }, - generationSettings: { detailLevel: 1 }, - }), - }); - return { status: r.status, body: (await r.text()).slice(0, 120) }; - }, - { token, arp } - ); - log?.info?.("ADOBE-FIREFLY", `Chrome ping generate status=${res.status}`); - return res.status === 200 || res.status === 202; - } catch (e) { - log?.warn?.( - "ADOBE-FIREFLY", - `Chrome ping error: ${e instanceof Error ? e.message : String(e)}` - ); - return false; - } -} - -/** - * Submit generate-async inside the warmed Chrome page (same TLS/cookie jar as SPA). - * Falls back to null so caller can use node fetch with the warmed ARP. - */ -export async function adobeFireflyGenerateInChrome(opts: { - accessToken: string; - arpSessionId: string; - payload: Record; - prompt: string; - log?: Log; -}): Promise<{ status: number; body: string; headers: Record } | null> { - if (!runtime?.page) return null; - try { - const res = await runtime.page.evaluate( - async ({ token, arp, payload, prompt }) => { - const claims = JSON.parse( - atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")) - ) as { user_id?: string }; - const data = new TextEncoder().encode( - String(claims.user_id || "") + "-" + String(prompt || "").slice(0, 256) - ); - const hash = await crypto.subtle.digest("SHA-256", data); - const nonce = [...new Uint8Array(hash)] - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - const r = await fetch("https://firefly-3p.ff.adobe.io/v2/3p-images/generate-async", { - method: "POST", - headers: { - Authorization: "Bearer " + token, - "x-api-key": "clio-playground-web", - "content-type": "application/json", - accept: "*/*", - "x-nonce": nonce, - "x-arp-session-id": arp, - }, - credentials: "include", - body: JSON.stringify(payload), - }); - const headers: Record = {}; - r.headers.forEach((v, k) => { - headers[k] = v; - }); - return { status: r.status, body: await r.text(), headers }; - }, - { - token: opts.accessToken, - arp: opts.arpSessionId, - payload: opts.payload, - prompt: opts.prompt, - } - ); - return res; - } catch (e) { - opts.log?.warn?.( - "ADOBE-FIREFLY", - `in-Chrome generate failed: ${e instanceof Error ? e.message : String(e)}` - ); - return null; - } -} - -/** Test helper */ -export function __resetAdobeFireflyChromeRuntimeForTests(): void { - runtime = null; - warmChain = Promise.resolve(); -} diff --git a/open-sse/services/aihordeImageCatalog.ts b/open-sse/services/aihordeImageCatalog.ts new file mode 100644 index 0000000000..a0cffa02a2 --- /dev/null +++ b/open-sse/services/aihordeImageCatalog.ts @@ -0,0 +1,220 @@ +/** + * Live AI Horde image-model detector. + * + * Horde workers appear and disappear. A static IMAGE_PROVIDERS list goes stale. + * This module polls `GET /v2/status/models?type=image` and keeps only models + * with at least one worker (`count > 0`). Names are the exact Horde strings + * (do not slugify). On poll failure the last good snapshot is kept. + */ + +import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; + +export const AI_HORDE_API_BASE = "https://aihorde.net/api"; +export const AI_HORDE_ANONYMOUS_KEY = "0000000000"; +export const AI_HORDE_CLIENT_AGENT = "OmniRoute:3.8.49:https://github.com/diegosouzapw/OmniRoute"; +export const AI_HORDE_CATALOG_POLL_MS = 30_000; +// The catalog endpoint is a fixed, trusted OmniRoute-controlled URL (not +// user-supplied), so it does not need SSRF host validation — but it still +// needs a hard bound so a hung upstream cannot block a request indefinitely. +export const AI_HORDE_CATALOG_FETCH_TIMEOUT_MS = 15_000; + +export interface HordeImageCatalogModel { + name: string; + count: number; + queued: number | null; + eta: number | null; + performance: number | null; + jobs: number | null; +} + +export interface HordeImageCatalogSnapshot { + models: HordeImageCatalogModel[]; + updatedAt: number | null; + lastError: string | null; +} + +type HordeFetchInit = RequestInit & { timeoutMs?: number }; +type HordeFetch = (input: string, init?: HordeFetchInit) => Promise; + +// Bounded default transport: fixed trusted host (guard "none"), abort-aware +// timeout. Callers that inject a custom `fetchImpl` (tests, alternate +// transports) opt out of this bound deliberately. +const defaultHordeFetch: HordeFetch = (input, init) => { + const { timeoutMs, ...rest } = init || {}; + return safeOutboundFetch(input, { + guard: "none", + timeoutMs: timeoutMs ?? AI_HORDE_CATALOG_FETCH_TIMEOUT_MS, + ...rest, + }); +}; + +function asNumber(value: unknown): number | null { + if (value === null || value === undefined) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function asInt(value: unknown): number | null { + const parsed = asNumber(value); + return parsed === null ? null : Math.trunc(parsed); +} + +/** + * Keep image models that currently have at least one worker. + * @throws {Error} when the payload is not a JSON array + */ +export function parseHordeImageModels(payload: unknown): HordeImageCatalogModel[] { + if (!Array.isArray(payload)) { + throw new Error("Horde model catalog must be a JSON array"); + } + + const models: HordeImageCatalogModel[] = []; + for (const item of payload) { + if (!item || typeof item !== "object") continue; + const row = item as Record; + const name = row.name; + if (typeof name !== "string" || !name.trim()) continue; + const modelType = row.type ?? "image"; + if (modelType !== null && modelType !== "image") continue; + const count = asInt(row.count ?? 0) ?? 0; + if (count <= 0) continue; + models.push({ + name, + count, + queued: asNumber(row.queued), + eta: asInt(row.eta), + performance: asNumber(row.performance), + jobs: asNumber(row.jobs), + }); + } + models.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })); + return models; +} + +export class HordeImageCatalog { + pollMs: number; + private models = new Map(); + private updatedAt: number | null = null; + private lastError: string | null = null; + private inflight: Promise | null = null; + private fetchImpl: HordeFetch; + + constructor(options: { pollMs?: number; fetchImpl?: HordeFetch } = {}) { + this.pollMs = Math.max(5_000, options.pollMs ?? AI_HORDE_CATALOG_POLL_MS); + this.fetchImpl = options.fetchImpl ?? defaultHordeFetch; + } + + get snapshot(): HordeImageCatalogSnapshot { + return { + models: this.listModels(), + updatedAt: this.updatedAt, + lastError: this.lastError, + }; + } + + get stale(): boolean { + return this.lastError !== null && this.updatedAt !== null; + } + + listModels(): HordeImageCatalogModel[] { + return [...this.models.values()].sort((a, b) => + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) + ); + } + + get(name: string): HordeImageCatalogModel | undefined { + return this.models.get(name); + } + + isServed(name: string): boolean { + const model = this.models.get(name); + return Boolean(model && model.count > 0); + } + + hasSnapshot(): boolean { + return this.updatedAt !== null; + } + + replace(models: HordeImageCatalogModel[], error: string | null = null): void { + this.models = new Map(models.map((model) => [model.name, model])); + if (error === null) { + this.updatedAt = Date.now(); + this.lastError = null; + } else { + this.lastError = error; + } + } + + /** Drop the snapshot so the next `ensureFresh` must hit Horde. */ + clear(): void { + this.models = new Map(); + this.updatedAt = null; + this.lastError = null; + } + + setFetch(fetchImpl: HordeFetch): void { + this.fetchImpl = fetchImpl; + } + + async refresh(options: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise { + if (this.inflight) return this.inflight; + this.inflight = this.refreshOnce(options).finally(() => { + this.inflight = null; + }); + return this.inflight; + } + + async ensureFresh( + maxAgeMs = this.pollMs, + options: { timeoutMs?: number; signal?: AbortSignal } = {} + ): Promise { + if (this.updatedAt !== null && Date.now() - this.updatedAt < maxAgeMs && !this.lastError) { + return; + } + await this.refresh(options); + } + + private async refreshOnce(options: { timeoutMs?: number; signal?: AbortSignal } = {}): Promise { + try { + const url = `${AI_HORDE_API_BASE}/v2/status/models?type=image`; + const response = await this.fetchImpl(url, { + method: "GET", + headers: { Accept: "application/json", "Client-Agent": AI_HORDE_CLIENT_AGENT }, + signal: options.signal, + timeoutMs: options.timeoutMs, + }); + if (!response.ok) { + throw new Error(`Horde catalog HTTP ${response.status}`); + } + const models = parseHordeImageModels(await response.json()); + this.replace(models); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.lastError = message; + } + } +} + +export const aiHordeImageCatalog = new HordeImageCatalog(); + +export function resetAiHordeImageCatalog(): void { + aiHordeImageCatalog.clear(); +} + +export function getCachedAiHordeImageCatalogEntries(): Array<{ + id: string; + name: string; + provider: string; + supportedSizes: string[]; + inputModalities: string[]; + description?: string; +}> { + return aiHordeImageCatalog.listModels().map((model) => ({ + id: `aihorde/${model.name}`, + name: `${model.name} (AI Horde)`, + provider: "aihorde", + supportedSizes: ["512x512", "768x768", "1024x1024", "1024x768", "768x1024"], + inputModalities: ["text", "image"], + description: `${model.count} worker${model.count === 1 ? "" : "s"} online`, + })); +} diff --git a/open-sse/services/autoCombo/chaosEngine.ts b/open-sse/services/autoCombo/chaosEngine.ts index 08e581e3fc..89813fe48f 100644 --- a/open-sse/services/autoCombo/chaosEngine.ts +++ b/open-sse/services/autoCombo/chaosEngine.ts @@ -25,6 +25,7 @@ */ import { errorResponse } from "../../utils/error.ts"; +import type { PerTargetAdmissionHook } from "../admission/types.ts"; import type { ComboLogger, HandleSingleModel } from "../combo/types.ts"; export const CHAOS_DEFAULTS = { @@ -363,8 +364,19 @@ export async function handleChaosChat(opts: { comboName?: string; primaryModel?: string | null; tuning?: ChaosTuning | null; + /** #9654 Wave 2: per-target lane-aware admission probe (see HandleComboChatOptions). */ + perTargetAdmission?: PerTargetAdmissionHook | null; }): Promise { - const { body, models, handleSingleModel, log, comboName, primaryModel, tuning } = opts; + const { + body, + models, + handleSingleModel, + log, + comboName, + primaryModel, + tuning, + perTargetAdmission, + } = opts; const panel = Array.isArray(models) ? models.filter(Boolean) : []; const hardTimeout = tuning?.panelHardTimeoutMs ?? CHAOS_DEFAULTS.panelHardTimeoutMs; const minPanel = tuning?.minPanel ?? CHAOS_DEFAULTS.minPanel; @@ -406,7 +418,29 @@ export async function handleChaosChat(opts: { const abortControllers: AbortController[] = []; - const modelPromises = panel.map((model, index) => { + // #9654 Wave 2: per-target lane-aware admission probe — drop lane-full + // panel members before fan-out (strictly non-blocking; no-op when off). + let panelToDispatch = panel; + if (perTargetAdmission) { + const gates = await Promise.all( + panel.map(async (model) => ({ + model, + ok: await perTargetAdmission({ modelStr: model, executionKey: model, body }), + })) + ); + const dropped = gates.filter((g) => !g.ok); + if (dropped.length > 0) { + log?.info?.( + "CHAOS", + `Skipping ${dropped.length} panel member(s) — admission lane full: ${dropped + .map((g) => g.model) + .join(", ")}` + ); + } + panelToDispatch = gates.filter((g) => g.ok).map((g) => g.model); + } + + const modelPromises = panelToDispatch.map((model, index) => { const ctrl = new AbortController(); abortControllers.push(ctrl); return dispatchOnePanelModel({ @@ -433,7 +467,7 @@ export async function handleChaosChat(opts: { if (successes.length === 0) { const errText = "All chaos panel models failed"; - await safeEnqueue(chatChunk(chunkId, panel[0], errText)); + await safeEnqueue(chatChunk(chunkId, panelToDispatch[0] ?? "", errText)); await safeEnqueue(SSE_DONE); await enqueueChain; closed = true; @@ -490,8 +524,10 @@ export function dispatchChaosFromCombo(args: { body: Body; handleSingleModel: HandleSingleModel; log: ComboLogger; + /** #9654 Wave 2: per-target lane-aware admission probe (see HandleComboChatOptions). */ + perTargetAdmission?: PerTargetAdmissionHook | null; }): Promise | null { - const { cfg, comboModels, comboName, body, handleSingleModel, log } = args; + const { cfg, comboModels, comboName, body, handleSingleModel, log, perTargetAdmission } = args; if ( !cfg.chaos || typeof cfg.chaos !== "object" || @@ -522,5 +558,6 @@ export function dispatchChaosFromCombo(args: { comboName, primaryModel: chaosCfg.judgeModel, tuning: chaosCfg.tuning, + perTargetAdmission, }); } diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 45b0397208..a55a70686b 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -44,6 +44,23 @@ export interface AutoComboSpec { family?: ModelFamily; } +/** Rate-limit empty-pool AUTO warns (same label can be resolved many times/min). */ +const emptyPoolWarnAt = new Map(); +export const EMPTY_POOL_WARN_INTERVAL_MS = 60_000; + +export function warnEmptyAutoPoolOnce(label: string, message: string, now = Date.now()): boolean { + const last = emptyPoolWarnAt.get(label) ?? 0; + if (now - last < EMPTY_POOL_WARN_INTERVAL_MS) return false; + emptyPoolWarnAt.set(label, now); + log.warn("AUTO", message); + return true; +} + +/** Test-only: reset the debounce map. */ +export function resetEmptyAutoPoolWarnStateForTests(): void { + emptyPoolWarnAt.clear(); +} + /** Minimal connection shape needed for virtual auto-combo factory */ interface VirtualFactoryConn extends ConnectionFields { id: string; @@ -692,8 +709,8 @@ export async function createVirtualAutoComboFromPrepared( // Family combos always degrade to an empty pool when unavailable — a family // is a hard identity constraint, not a soft optimization bias, so there is // no sensible "fall back to the full pool" behavior for it. - log.warn( - "AUTO", + warnEmptyAutoPoolOnce( + label, `${label} matched no connected models; returning an empty pool.${spec?.family ? "" : ' Set OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true to restore the legacy "use full pool" behavior.'}` ); effectivePool = []; diff --git a/open-sse/services/claudeCodeCompatible.ts b/open-sse/services/claudeCodeCompatible.ts index 79d2779881..12480187e2 100644 --- a/open-sse/services/claudeCodeCompatible.ts +++ b/open-sse/services/claudeCodeCompatible.ts @@ -56,14 +56,6 @@ const CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS = [ text: "You are a Claude agent, built on Anthropic's Claude Agent SDK.", }, ]; -const CONTEXT_1M_SUPPORTED_MODELS = [ - "claude-fable-5", - "claude-sonnet-5", - "claude-sonnet-4-6", - "claude-opus-4-8", - "claude-opus-4-7", - "claude-opus-4-6", -]; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS = getStainlessTimeoutSeconds( process.env ); @@ -168,16 +160,9 @@ export function appendAnthropicBetaHeader( } } -export function modelSupportsContext1mBeta(model: string | null | undefined): boolean { - const normalizedModel = String(model || "") - .trim() - .toLowerCase() - .replace(/-\d{8}$/, ""); - - return CONTEXT_1M_SUPPORTED_MODELS.some( - (supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`) - ); -} +// Re-exported from the shared context1m module so existing importers of this +// helper (base.ts) keep working; the eligibility list now has one source of truth. +export { modelSupportsContext1mBeta } from "../config/context1m.ts"; export function buildClaudeCodeCompatibleHeaders( apiKey: string, diff --git a/open-sse/services/claudeCodeToolRemapper.ts b/open-sse/services/claudeCodeToolRemapper.ts index 4d93650401..15995a9500 100644 --- a/open-sse/services/claudeCodeToolRemapper.ts +++ b/open-sse/services/claudeCodeToolRemapper.ts @@ -21,16 +21,43 @@ const TOOL_RENAME_MAP: Record = { glob: "Glob", grep: "Grep", task: "Task", + agent: "Agent", webfetch: "WebFetch", websearch: "WebSearch", todowrite: "TodoWrite", todoread: "TodoRead", question: "Question", + askuserquestion: "AskUserQuestion", skill: "Skill", + slashcommand: "SlashCommand", multiedit: "MultiEdit", notebook: "Notebook", + notebookedit: "NotebookEdit", + notebookread: "NotebookRead", lsp: "Lsp", apply_patch: "ApplyPatch", + applypatch: "ApplyPatch", + bashoutput: "BashOutput", + killshell: "KillShell", + killbash: "KillBash", + enterplanmode: "EnterPlanMode", + exitplanmode: "ExitPlanMode", + enterworktree: "EnterWorktree", + exitworktree: "ExitWorktree", + artifact: "Artifact", + designsync: "DesignSync", + monitor: "Monitor", + sendmessage: "SendMessage", + listagents: "ListAgents", + pushnotification: "PushNotification", + reportfindings: "ReportFindings", + schedulewakeup: "ScheduleWakeup", + croncreate: "CronCreate", + crondelete: "CronDelete", + cronlist: "CronList", + taskoutput: "TaskOutput", + taskstop: "TaskStop", + workflow: "Workflow", }; const REVERSE_MAP: Record = {}; @@ -160,7 +187,6 @@ export function remapToolNamesInResponse( ): string { if (!forceLowercase) return text; - // Replace TitleCase tool names back to lowercase in SSE chunks if (toolNameMap?.size) { for (const [mapped, original] of toolNameMap.entries()) { text = text.replaceAll(`"name":"${mapped}"`, `"name":"${original}"`); @@ -206,6 +232,15 @@ export function restoreClaudeToolName( } } + // When no request toolNameMap is provided (e.g. non-Claude client): + // If rawName is already TitleCase, apply REVERSE_MAP for #7926 backward compatibility (Bash → bash). + if (!toolNameMap && REVERSE_MAP[rawName]) { + return REVERSE_MAP[rawName]; + } + + const canonical = TOOL_RENAME_MAP[rawName.toLowerCase()]; + if (canonical) return canonical; + return REVERSE_MAP[rawName] ?? rawName; } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index df86a79375..0abacad590 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -86,13 +86,43 @@ import { import { selectQuotaShareTarget } from "./combo/quotaShareStrategy.ts"; import { makeConnectionConcurrencyResolver, lookupPositiveCap } from "./combo/concurrencyCaps.ts"; import { acquireQuotaShareConcurrencySlot } from "./combo/quotaShareConcurrency.ts"; +import { canAffordRequest } from "../../src/lib/quota/quotaScheduler.ts"; +import { getCachedProviderConnectionById } from "../../src/lib/localDb.js"; import { orderTargetsByEvalScores } from "./evalRouting.ts"; + +/** + * Resolve the configured per-connection token budget (rateLimitOverrides.tpm) + * for quota reservation. Returns undefined when unconfigured — the store then + * keeps the previously recorded limit (or 0 for a fresh row, meaning "no + * budget enforced"). + */ +function resolveTargetTokenLimit(target: { connectionId?: string | null }): number | undefined { + const connectionId = target?.connectionId; + if (!connectionId) return undefined; + try { + const connection = getCachedProviderConnectionById(connectionId); + const overrides = (connection as { rateLimitOverrides?: Record | null } | null) + ?.rateLimitOverrides; + const tpm = overrides?.tpm; + return typeof tpm === "number" && tpm > 0 ? tpm : undefined; + } catch { + return undefined; + } +} import { applyPromptCacheAffinity, expandPromptCacheAffinityTargets, expandPromptCacheAffinityTargetsFromConnections, resolvePromptCacheAffinityKey, } from "./combo/promptCacheAffinity.ts"; +import { + classifyComboOutcome, + formatComboOutcomes, + redactConnectionLabel, + buildRedactedSummary, + resolveComboTerminalStatus, +} from "./combo/comboErrorAggregation.ts"; +import type { ComboErrorEntry } from "./combo/comboErrorAggregation.ts"; import type { CompressionMode } from "./compression/types.ts"; import { getCachedProviderConnections } from "../../src/lib/db/readCache"; import { isProviderInCooldown, recordProviderCooldown } from "./providerCooldownTracker.ts"; @@ -591,6 +621,12 @@ export async function handleComboChat({ nesting = null, hiddenModelsByProvider = getHiddenModelsByProvider(), clientManagedResponsesContext = false, + perTargetAdmission = null, + deferContextOverflowWhenCompressible = false, + compressionExclusions, + sourceFormat = null, + endpointPath = null, + requestHeaders = null, }: HandleComboChatOptions): Promise { const comboCtx = createComboContext({ body, combo, settings, relayOptions, log }); const { @@ -651,6 +687,12 @@ export async function handleComboChat({ signal, apiKeyAllowedConnections, hiddenModelsByProvider, + perTargetAdmission, + deferContextOverflowWhenCompressible, + compressionExclusions, + sourceFormat, + endpointPath, + requestHeaders, runCombo: handleComboChat, }); if (fusionDispatch) return fusionDispatch; @@ -669,6 +711,7 @@ export async function handleComboChat({ body, handleSingleModel: handleSingleModelWithTimeout, log, + perTargetAdmission, }); if (chaosDispatch) return chaosDispatch; @@ -700,6 +743,12 @@ export async function handleComboChat({ signal, apiKeyAllowedConnections, hiddenModelsByProvider, + perTargetAdmission, + deferContextOverflowWhenCompressible, + compressionExclusions, + sourceFormat, + endpointPath, + requestHeaders, runCombo: handleComboChat, }); if (runtimeUnitDispatch) return runtimeUnitDispatch; @@ -723,7 +772,13 @@ export async function handleComboChat({ signal, hiddenModelsByProvider, clientManagedResponsesContext, + deferContextOverflowWhenCompressible, + compressionExclusions, + sourceFormat, + endpointPath, + requestHeaders, relayOptions, + perTargetAdmission, }); } @@ -750,6 +805,11 @@ export async function handleComboChat({ buildAutoCandidates, hiddenModelsByProvider, clientManagedResponsesContext, + deferContextOverflowWhenCompressible, + compressionExclusions, + sourceFormat, + endpointPath, + requestHeaders, }); if ("earlyResponse" in targetResolution) return targetResolution.earlyResponse; const { stickyWeightedLimit, getWeightedStepKeyForTarget, preScreenMap } = targetResolution; @@ -853,7 +913,7 @@ export async function handleComboChat({ let comboExpired = false; // Accumulator for per-model error details across targets in the current set try. // Reset at the start of each set retry (same lifecycle as lastError/recordedAttempts). - let comboErrors: Array<{ model: string; status: number; error: string }> = []; + let comboErrors: Array = []; // Quota trust spans set retries and recursive cooldown re-dispatches. Once any // failure is non-quota, a nested caller must never treat this dispatch as quota-only. let observedFailure = false; @@ -1069,6 +1129,27 @@ export async function handleComboChat({ } } + // Quota-aware scheduling (opt-in, OMNIROUTE_QUOTA_AWARE_ROUTING=1): + // when a per-connection token budget is configured (provider_quota_state), + // skip targets whose remaining budget cannot afford this request — + // BEFORE dispatching — instead of waiting for a 429. Fails open: when + // no budget is configured the decision is always affordable. + if (process.env.OMNIROUTE_QUOTA_AWARE_ROUTING === "1" && provider && target.connectionId) { + const quotaDecision = canAffordRequest( + target.connectionId, + modelStr, + body as Record | null | undefined + ); + if (!quotaDecision.affordable) { + log.info( + "COMBO", + `Skipping ${modelStr} — quota budget ${quotaDecision.reason} (remaining ${quotaDecision.tokensRemaining ?? 0}, cost ${quotaDecision.estimatedCost ?? 0})` + ); + if (i > 0) fallbackCount++; + return null; + } + } + // Pre-screen snapshot is NOT used as a permanent skip — availability // is always re-checked via isModelAvailable below because connection // cooldowns can expire between setTry retries, making a previously @@ -1109,6 +1190,21 @@ export async function handleComboChat({ if (i > 0) fallbackCount++; return stopProtectedPriorityTarget(`Connection capacity reached for ${modelStr}`); } + + } + + // #9654 Wave 2: per-target lane-aware admission probe. With virtual + // lanes on, a tenant whose lane queue is full should skip extra + // fan-out targets instead of piling more queued work onto the lane. + // Strictly non-blocking (maxWaitMs 0) and a no-op when lanes are off — + // see createPerTargetAdmissionHook for the full contract. + if ( + perTargetAdmission && + !(await perTargetAdmission({ modelStr, executionKey: target.executionKey, body })) + ) { + log.info("COMBO", `Skipping ${modelStr} — admission lane full (#9654)`); + if (i > 0) fallbackCount++; + return null; } // Retry loop for transient errors @@ -1343,6 +1439,15 @@ export async function handleComboChat({ // misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality. lastError = `Upstream response failed quality validation: ${quality.reason}`; lastStatus = 502; + // #10314: record quality failures as a FIRST-CLASS per-target outcome + // so a quality reason is never silently dropped from the aggregated + // terminal message when a later sibling overwrites lastError. + comboErrors.push({ + model: modelStr, + status: 502, + error: quality.reason || "upstream response failed quality validation", + kind: "quality", + }); if (i > 0) fallbackCount++; if (provider && rawModel) { const mlSettings = resolveModelLockoutSettings(settings); @@ -1850,6 +1955,7 @@ export async function handleComboChat({ model: modelStr, status: result.status, error: errorText || String(result.status), + kind: classifyComboOutcome(result.status, errorText), }); lastStatus = result.status; if (i > 0) fallbackCount++; @@ -2043,6 +2149,7 @@ export async function handleComboChat({ model: modelStr, status: result.status, error: errorText || String(result.status), + kind: classifyComboOutcome(result.status, errorText), }); lastStatus = result.status; if (i > 0) fallbackCount++; @@ -2197,15 +2304,10 @@ export async function handleComboChat({ // Global combo timeout: return aggregated error immediately, skipping set retries. if (comboExpired) { - const summary = comboErrors - .slice(0, 5) - .map((e) => `${e.model} (${e.status})`) - .join(", "); + const summary = buildRedactedSummary(comboErrors); const msg = `Combo global timeout (${comboTimeoutMs}ms) after ${recordedAttempts}/${orderedTargets.length} targets` + - (comboErrors.length > 0 - ? ` | tried: ${summary}${comboErrors.length > 5 ? `... (+${comboErrors.length - 5})` : ""}` - : ""); + (comboErrors.length > 0 ? ` | tried: ${summary}` : ""); const latencyMs = Date.now() - startTime; if (recordedAttempts === 0) { recordComboRequest(combo.name, null, { @@ -2275,19 +2377,20 @@ export async function handleComboChat({ ); } - const status = lastStatus; - // Build aggregated error message with per-model failure details for diagnostics. - const comboErrorSummary = - comboErrors.length > 0 - ? " [" + - comboErrors - .slice(0, 5) - .map((e) => `${e.model} (${e.status})`) - .join(", ") + - (comboErrors.length > 5 ? `... (+${comboErrors.length - 5})` : "") + - "]" - : ""; - const msg = (lastError || "All combo models unavailable") + comboErrorSummary; + // #10501: derive the terminal HTTP status from the structured per-target + // outcomes instead of `lastStatus` (whichever target happened to fail + // LAST). A 4xx is preserved only when the request itself is genuinely + // invalid across every eligible target; a heterogeneous mix of failure + // classes (e.g. a quality failure + a sibling's 401) normalizes to a + // 5xx-class status reflecting an infra/provider problem, not a client + // error. See comboErrorAggregation.ts::resolveComboTerminalStatus. + const status = resolveComboTerminalStatus(comboErrors, lastStatus); + // #10314: build the terminal message from the structured per-target + // outcomes (each distinct class+reason listed separately) instead of + // mashing a single lastError with raw `[model (status)]` markers. Connection + // identifiers are redacted. Falls back to lastError when no target recorded + // a structured outcome. + const msg = formatComboOutcomes(comboErrors) || lastError || "All combo models unavailable"; // Cooldown-aware retry: instead of crystallizing a transient failure, wait // out a SHORT cooldown and re-run the whole set loop. Guarded by the helper @@ -2441,7 +2544,13 @@ async function handleRoundRobinCombo({ nesting = null, hiddenModelsByProvider = getHiddenModelsByProvider(), clientManagedResponsesContext, + deferContextOverflowWhenCompressible = false, + compressionExclusions, + sourceFormat = null, + endpointPath = null, + requestHeaders = null, relayOptions, + perTargetAdmission = null, }: HandleRoundRobinOptions): Promise { const config = settings ? resolveComboConfig(combo, settings) @@ -2498,6 +2607,11 @@ async function handleRoundRobinCombo({ const evalRankedTargets = orderTargetsByEvalScores(tagFilteredTargets, config.evalRouting, log); const knownContextOverflow = getKnownContextOverflow(evalRankedTargets, body, { clientManagedResponsesContext, + deferContextOverflowWhenCompressible, + compressionExclusions, + sourceFormat, + endpointPath, + requestHeaders, }); if (knownContextOverflow) { return errorResponseWithComboDiagnostics( @@ -2715,6 +2829,10 @@ async function handleRoundRobinCombo({ let globalAttempts = 0; let fallbackCount = 0; let recordedAttempts = 0; + // #10314: per-target outcome accumulator for the round-robin twin so the + // terminal message lists each distinct reason separately (see the quality path + // and the "Done with this model" path below), mirroring handleComboChat. + const rrOutcomes: Array = []; // #1731: Per-request in-memory set of providers whose quota is fully exhausted. // When a target returns a quota-exhausted 429, remaining targets from the same @@ -2772,6 +2890,17 @@ async function handleRoundRobinCombo({ continue; } + // #9654 Wave 2: per-target lane-aware admission probe (see executeTarget + // for the full contract — strictly non-blocking, lanes-off no-op). + if ( + perTargetAdmission && + !(await perTargetAdmission({ modelStr, executionKey: target.executionKey, body })) + ) { + log.info("COMBO-RR", `Skipping ${modelStr} — admission lane full (#9654)`); + if (offset > 0) fallbackCount++; + continue; + } + // Acquire semaphore slot (may wait in queue). Honor the connection's own // maxConcurrent cap when set; else fall back to the combo-level concurrency. const targetConcurrency = await resolveTargetConcurrency(target.connectionId); @@ -2865,6 +2994,25 @@ async function handleRoundRobinCombo({ failoverBeforeRetry: config.failoverBeforeRetry, }); + // Quota-aware scheduling: reserve the estimated budget for this + // dispatch (opt-in, same env gate as the pre-request check). Best-effort + // and non-blocking — recording must never break the request path. + if ( + process.env.OMNIROUTE_QUOTA_AWARE_ROUTING === "1" && + target.connectionId && + attemptBody && + typeof attemptBody === "object" + ) { + try { + const { reserveQuota } = await import("../../src/lib/quota/quotaScheduler.ts"); + reserveQuota(target.connectionId, modelStr, attemptBody as Record, { + tokenLimit: resolveTargetTokenLimit(target), + }); + } catch { + // best-effort only + } + } + // Success — validate response quality before returning if (result.ok) { let rrClone: Response; @@ -2911,6 +3059,12 @@ async function handleRoundRobinCombo({ // misleading ALL_ACCOUNTS_INACTIVE when the real issue is quality. lastError = `Upstream response failed quality validation: ${quality.reason}`; lastStatus = 502; + rrOutcomes.push({ + model: modelStr, + status: 502, + error: quality.reason || "upstream response failed quality validation", + kind: "quality", + }); if (offset > 0) fallbackCount++; break; // move to next model } @@ -3217,6 +3371,12 @@ async function handleRoundRobinCombo({ recordedAttempts++; lastError = errorText || String(result.status); lastStatus = result.status; + rrOutcomes.push({ + model: modelStr, + status: result.status, + error: errorText || String(result.status), + kind: classifyComboOutcome(result.status, errorText), + }); if (offset > 0) fallbackCount++; log.warn("COMBO-RR", `${modelStr} failed, trying next model`, { status: result.status }); @@ -3336,8 +3496,13 @@ async function handleRoundRobinCombo({ ); } - const status = lastStatus; - const msg = lastError || "All round-robin combo models unavailable"; + // #10501: same terminal-status policy as handleComboChat — see + // comboErrorAggregation.ts::resolveComboTerminalStatus. + const status = resolveComboTerminalStatus(rrOutcomes, lastStatus); + // #10314: same structured per-target aggregation as handleComboChat — list each + // distinct reason separately (redacted), fall back to lastError when no outcome. + const msg = + formatComboOutcomes(rrOutcomes) || lastError || "All round-robin combo models unavailable"; if (earliestRetryAfter && isRetryAfterEligibleStatus(status)) { const retryHuman = formatRetryAfter(toRetryAfterDisplayValue(earliestRetryAfter)); diff --git a/open-sse/services/combo/comboErrorAggregation.ts b/open-sse/services/combo/comboErrorAggregation.ts new file mode 100644 index 0000000000..c7b80fdad4 --- /dev/null +++ b/open-sse/services/combo/comboErrorAggregation.ts @@ -0,0 +1,179 @@ +/** + * Shared combo terminal-error aggregation. + * + * #10314 — combo error aggregation mixes quality and auth. Prior to this module + * the combo terminal message was built as a single `lastError` string (last + * writer wins — it can only ever represent ONE target's reason) concatenated + * with a raw `[model (status)]` suffix. A quality-failure reason from one + * target and a sibling's 401 were collapsed into one client-facing sentence + * (`invalid_api_key [openai/proxy-account-b (401)]`) and a quality reason that + * was not the final failing target was dropped entirely. + * + * This module gives each per-target failure a structured {model, status, error, + * kind} entry, so the terminal message can list every distinct reason + * separately (and classification-labelled) instead of mashing them, and it + * redacts connection/account identifiers that, on openai-compatible proxy + * connections, used to surface verbatim in client-visible and shared-warn + * strings (ops/PII leak). + */ + +export type ComboOutcomeKind = + | "quality" + | "auth" + | "rate_limit" + | "model" + | "provider" + | "timeout" + | "skipped" + | "upstream"; + +export interface ComboErrorEntry { + model: string; + status: number; + error: string; + kind: ComboOutcomeKind; +} + +const KIND_LABELS: Record = { + quality: "quality validation", + auth: "auth", + rate_limit: "rate limit", + model: "model", + provider: "provider", + timeout: "timeout", + skipped: "skipped", + upstream: "upstream", +}; + +/** + * Classify a single target's terminal outcome for the client-facing message. + * Auth-class errors (401/403 or auth-sounding text) are kept distinct from + * model-class (400/422) and provider-class (5xx) so a sibling's 401 is never + * presented as "quality failed". Fall through to `model` for everything else. + * + * #10501: the ordering below is deliberate and load-bearing — the timeout + * check MUST use an exact match (408 / 499), never `status >= 499`. A `>=` + * comparison there swallows every 5xx status too (500 >= 499), which made the + * `status >= 500` branch permanently unreachable and silently mislabeled every + * real provider outage (500/502/503/504) as a client-side "timeout". 429 is + * also given its own explicit branch: a rate-limit/quota signal is neither a + * "the client's request is invalid" (`model`) nor a hard provider outage, and + * lumping it into `model` would make `resolveComboTerminalStatus` treat a + * heterogeneous 429 mix as a genuinely-invalid-request case by accident. + */ +export function classifyComboOutcome(status: number, errorText: string): ComboOutcomeKind { + const text = typeof errorText === "string" ? errorText : ""; + if ( + status === 401 || + status === 403 || + /(invalid.?api.?key|unauthorized|not.?authorized|auth(entication|orization)?)/i.test(text) + ) { + return "auth"; + } + if (status === 429) return "rate_limit"; + if (status === 408 || status === 499) return "timeout"; + if (status >= 500) return "provider"; + return "model"; +} + +/** + * Redact connection/account identifiers that can ride inside a proxy target's + * model string (openai-compatible proxy model names often carry a connection + * label). UUIDs and long hex hashes are truncated to a short `conn:` prefix. + * Provider/model names operators need for debugging are left intact. + */ +export function redactConnectionLabel(modelStr: string | null | undefined): string { + const label = typeof modelStr === "string" && modelStr ? modelStr : "unknown"; + return label + .replace( + /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g, + (m) => `conn:${m.slice(0, 8)}` + ) + .replace(/\b[0-9a-fA-F]{16,}\b/g, (m) => `conn:${m.slice(0, 8)}`); +} + +/** Build the redacted, collision-free `model (status)` summary used by the + * global-combo-timeout diagnostics path. */ +export function buildRedactedSummary( + entries: Array<{ model: string; status: number }> | ReadonlyArray<{ model: string; status: number }> +): string { + const slice = entries.slice(0, 5); + const parts = slice.map((e) => `${redactConnectionLabel(e.model)} (${e.status})`).join(", "); + return entries.length > 5 ? `${parts}... (+${entries.length - 5})` : parts; +} + +/** + * Format per-target terminal outcomes into one client-facing sentence that keeps + * every distinct reason separate (and classification-labelled) instead of + * mashing a single `lastError` with raw status markers. Always redacts + * connection identifiers unless `{ redact: false }` is explicitly passed. + */ +export function formatComboOutcomes( + entries: ReadonlyArray<{ model: string; status: number; error: string; kind?: ComboOutcomeKind }>, + opts?: { redact?: boolean } +): string { + if (!entries.length) return ""; + const redact = opts?.redact !== false; + const slice = entries.slice(0, 5); + const parts = slice.map((e) => { + const label = redact ? redactConnectionLabel(e.model) : e.model; + const kind = e.kind ? KIND_LABELS[e.kind] ?? e.kind : null; + // #10501: the raw upstream error TEXT can itself carry a connection/account + // identifier (some openai-compatible proxies echo it back in the error body, + // e.g. "invalid key for connection ") — redact it here too, not just + // the model label above, or the identifier leaks into the client-facing + // terminal message regardless of the label redaction. + const rawReason = e.error || `HTTP ${e.status}`; + const reason = redact ? redactConnectionLabel(rawReason) : rawReason; + const statusTxt = ` (HTTP ${e.status})`; + return kind ? `${label}: ${kind} — ${reason}${statusTxt}` : `${label}: ${reason}${statusTxt}`; + }); + return entries.length > 5 + ? `${parts.join("; ")}... (+${entries.length - 5} more)` + : parts.join("; "); +} + +/** + * #10501: explicit terminal-status policy for heterogeneous combo target + * exhaustion. Prior behavior returned `lastStatus` — whichever target + * happened to fail LAST, independent of what the other targets failed with. + * That let an unrelated target's config-class 4xx (or a target's own auth + * failure) masquerade as the combo's overall verdict, and vice versa. + * + * Policy: + * - No structured entries: keep the caller's fallback status unchanged. + * - Every entry is `model`-class AND a genuine 4xx (the request itself is + * invalid on EVERY eligible target, homogeneous or not): preserve that + * 4xx — this is a real client-request error, not an infra problem. + * - All entries share the SAME kind (any kind, e.g. every target failed + * with `auth`, or every target was `rate_limit`): preserve that shared + * class's own status — a uniform reason across all targets is still a + * single, well-defined verdict. + * - Otherwise (a genuine MIX of different failure classes — e.g. a quality + * failure on one target and a 401 on a sibling): this is heterogeneous by + * definition, so it is normalized to a 5xx-class infra/provider status + * instead of surfacing whichever target's status happened to be recorded + * last. `timeout` present anywhere in the mix maps to 504 (Gateway + * Timeout); otherwise 502 (Bad Gateway) — combo routing itself is the + * "gateway" that could not complete the request via any target. + */ +export function resolveComboTerminalStatus( + entries: ReadonlyArray, + fallbackStatus: number +): number { + if (!entries.length) return fallbackStatus; + + const allGenuinelyInvalidRequest = entries.every( + (e) => e.kind === "model" && e.status >= 400 && e.status < 500 + ); + if (allGenuinelyInvalidRequest) { + return entries[entries.length - 1].status; + } + + const distinctKinds = new Set(entries.map((e) => e.kind)); + if (distinctKinds.size === 1) { + return entries[entries.length - 1].status; + } + + return entries.some((e) => e.kind === "timeout") ? 504 : 502; +} \ No newline at end of file diff --git a/open-sse/services/combo/dispatchPrelude.ts b/open-sse/services/combo/dispatchPrelude.ts index 7caf82fe76..2f5361a4cf 100644 --- a/open-sse/services/combo/dispatchPrelude.ts +++ b/open-sse/services/combo/dispatchPrelude.ts @@ -55,6 +55,7 @@ import type { ResolvedComboUnit, SingleModelTarget, } from "./types.ts"; +import type { PerTargetAdmissionHook } from "../admission/types.ts"; type ComboSetupConfig = ReturnType; type RunCombo = (options: HandleComboChatOptions) => Promise; @@ -76,6 +77,16 @@ type PreludeBaseOptionArgs = { apiKeyAllowedConnections?: string[] | null; hiddenModelsByProvider?: HiddenModelsByProvider; clientManagedResponsesContext?: boolean; + /** #9654 Wave 2: per-target lane-aware admission probe (see HandleComboChatOptions). */ + perTargetAdmission?: PerTargetAdmissionHook | null; + /** #10225 — defer the hard context-overflow preflight when compression is enabled. */ + deferContextOverflowWhenCompressible?: boolean; + /** Server-side compression exclusions (#8034). */ + compressionExclusions?: import("../compression/exclusions.ts").CompressionExclusions; + /** #10503 — request-shape facts for the target-aware deferral check (see knownContextOverflow.ts). */ + sourceFormat?: string | null; + endpointPath?: string | null; + requestHeaders?: Headers | Record | null; }; /** Rebuild handleComboChat's option bag verbatim for a recursive dispatch. */ @@ -93,6 +104,12 @@ function buildBaseOptions(a: PreludeBaseOptionArgs): HandleComboChatOptions { apiKeyAllowedConnections: a.apiKeyAllowedConnections, hiddenModelsByProvider: a.hiddenModelsByProvider, clientManagedResponsesContext: a.clientManagedResponsesContext, + perTargetAdmission: a.perTargetAdmission, + deferContextOverflowWhenCompressible: a.deferContextOverflowWhenCompressible, + compressionExclusions: a.compressionExclusions, + sourceFormat: a.sourceFormat, + endpointPath: a.endpointPath, + requestHeaders: a.requestHeaders, }; } @@ -366,6 +383,12 @@ export async function tryFusionDispatch(args: { signal?: AbortSignal | null; apiKeyAllowedConnections?: string[] | null; hiddenModelsByProvider?: HiddenModelsByProvider; + perTargetAdmission?: PerTargetAdmissionHook | null; + deferContextOverflowWhenCompressible?: boolean; + compressionExclusions?: import("../compression/exclusions.ts").CompressionExclusions; + sourceFormat?: string | null; + endpointPath?: string | null; + requestHeaders?: Headers | Record | null; runCombo: RunCombo; }): Promise { const { cfg, combo, config, strategy, log } = args; @@ -435,6 +458,7 @@ export async function tryFusionDispatch(args: { handleSingleModel: fusionHandleSingleModel, log, comboName: combo.name, + perTargetAdmission: args.perTargetAdmission, judgeModel, tuning: fusionTuning, }); @@ -589,6 +613,12 @@ export async function tryRuntimeUnitDispatch(args: { signal?: AbortSignal | null; apiKeyAllowedConnections?: string[] | null; hiddenModelsByProvider?: HiddenModelsByProvider; + perTargetAdmission?: PerTargetAdmissionHook | null; + deferContextOverflowWhenCompressible?: boolean; + compressionExclusions?: import("../compression/exclusions.ts").CompressionExclusions; + sourceFormat?: string | null; + endpointPath?: string | null; + requestHeaders?: Headers | Record | null; runCombo: RunCombo; }): Promise { const { body, combo, config, strategy, allCombos, log, settings } = args; diff --git a/open-sse/services/combo/fingerprintExpansion.ts b/open-sse/services/combo/fingerprintExpansion.ts index be3d511509..df4cf2b218 100644 --- a/open-sse/services/combo/fingerprintExpansion.ts +++ b/open-sse/services/combo/fingerprintExpansion.ts @@ -15,7 +15,7 @@ import type { ResolvedComboTarget } from "./types.ts"; /** Providers whose `providerSpecificData.fingerprints` array should be expanded. */ -const FINGERPRINT_PROVIDERS: ReadonlySet = new Set(["mimocode", "mcode", "opencode"]); +const FINGERPRINT_PROVIDERS: ReadonlySet = new Set(["opencode"]); /** Separator the combo builder UI uses to encode an account pin (#6087). */ const FP_PIN_SEPARATOR = "|fp|"; diff --git a/open-sse/services/combo/knownContextOverflow.ts b/open-sse/services/combo/knownContextOverflow.ts index db9cb2d602..532e1a4be6 100644 --- a/open-sse/services/combo/knownContextOverflow.ts +++ b/open-sse/services/combo/knownContextOverflow.ts @@ -17,6 +17,8 @@ */ import { getResolvedModelCapabilities } from "../modelCapabilities.ts"; +import { isCompressionExcluded, type CompressionExclusions } from "../compression/exclusions.ts"; +import { shouldUseNativeCodexPassthrough } from "../../handlers/chatCore/passthroughHelpers.ts"; import { deriveRequestCompatibilityRequirements } from "./comboStructure.ts"; import type { ResolvedComboTarget } from "./types.ts"; @@ -28,6 +30,29 @@ export type KnownContextOverflow = { targetCount: number; }; +export type KnownContextOverflowOptions = { + clientManagedResponsesContext?: boolean; + /** + * When prompt compression is enabled for this request (global compression switch + * AND not API-key opted-out), defer the hard preflight so chatCore's compression + * pipeline runs before the final context gate — instead of a raw-body estimate + * rejecting a compressible request up front. (#10225) + */ + deferContextOverflowWhenCompressible?: boolean; + /** Server-side compression exclusions (#8034) — targets matching one cannot run compression. */ + compressionExclusions?: CompressionExclusions; + /** + * #10503: the exact request-shape facts chatCore.ts uses to decide + * `shouldUseNativeCodexPassthrough` (open-sse/handlers/chatCore/passthroughHelpers.ts) — + * threaded down so the deferral decision below can be target-aware instead of + * relying on the looser `clientManagedResponsesContext` proxy. Reused verbatim + * (not re-derived) so the combo-layer decision can never drift from chatCore's own. + */ + sourceFormat?: string | null; + endpointPath?: string | null; + requestHeaders?: Headers | Record | null; +}; + // #7177: an empty array/object (e.g. a default `messages: []` some combo entrypoints inject // when the caller sent none) has no real content — counting it would charge a few phantom // "structural" tokens (JSON.stringify braces/brackets) toward the estimate, which is enough @@ -69,7 +94,7 @@ export function getKnownContextLimit( export function getKnownContextOverflow( targets: ResolvedComboTarget[], body: Record, - options: { clientManagedResponsesContext?: boolean } = {} + options: KnownContextOverflowOptions = {} ): KnownContextOverflow | null { if (targets.length === 0) return null; // Native Codex Responses clients compact their own item history. Let the concrete @@ -85,6 +110,55 @@ export function getKnownContextOverflow( ) { return null; } + // #10225 / #10499-sweep #10503: a conservative raw-body context estimate must not + // be treated as proof that a compression-enabled request cannot fit. When + // compression is available for this request AND at least one target can actually + // run it, defer the hard rejection so handleChatCore runs proactive compression + // (chatCore.ts) and its post-compression enforceOutputTokenBudget becomes the + // final context gate — returning a local `context_length_exceeded` only if the + // compressed body still cannot fit (no upstream dispatch). + // + // Target-awareness is load-bearing here: a target is only a valid reason to defer + // when handleChatCore will ACTUALLY attempt compression for it. Two classes are + // excluded from "can compress" even though `isCompressionExcluded` (operator + // exclusions) says nothing about them: + // - Operator-excluded targets (#8034, existing `isCompressionExcluded` check). + // - Native Codex Responses passthrough targets: chatCore.ts unconditionally sets + // `compressionExcluded = nativeCodexPassthrough || ...` for these, computed via + // `shouldUseNativeCodexPassthrough()` (chatCore/passthroughHelpers.ts) — called + // here with the SAME request-shape facts (sourceFormat/endpointPath/headers) + // chatCore itself uses, reused verbatim rather than re-derived from the looser + // `clientManagedResponsesContext` flag (which always requires a VERIFIED native + // client; chatCore's own gate does NOT for provider==="codex" — see + // shouldUseNativeCodexPassthrough's `provider === "codex" || isVerifiedNativeCodexRequest` + // short-circuit). Deferring on such a target's account would let an oversized + // body sail straight through to `fetch()` uncompressed instead of being caught + // by either preflight — silently defeating the whole point of this feature. + // If NO target can compress, the fast raw-body preflight is kept (unchanged). + if ( + options.deferContextOverflowWhenCompressible === true && + targets.some((target) => { + const isNativeCodexPassthroughTarget = shouldUseNativeCodexPassthrough({ + provider: target.provider, + sourceFormat: options.sourceFormat, + endpointPath: options.endpointPath, + body, + headers: options.requestHeaders, + }); + if (isNativeCodexPassthroughTarget) return false; + return !isCompressionExcluded( + { + provider: target.provider, + model: target.modelStr.includes("/") + ? target.modelStr.split("/").slice(1).join("/") + : target.modelStr, + }, + options.compressionExclusions + ); + }) + ) { + return null; + } const requirements = deriveRequestCompatibilityRequirements(body); if (requirements.requiredContextTokens <= 0) return null; diff --git a/open-sse/services/combo/nativeCodexTurnPin.ts b/open-sse/services/combo/nativeCodexTurnPin.ts index 5ffa6bcf70..4fc175b933 100644 --- a/open-sse/services/combo/nativeCodexTurnPin.ts +++ b/open-sse/services/combo/nativeCodexTurnPin.ts @@ -74,12 +74,12 @@ export function pinNativeCodexTurn(args: { const existing = pins.get(key); if ( existing && - (existing.modelStr !== args.target.modelStr || - existing.provider !== args.target.provider || - existing.connectionId !== args.connectionId) + (existing.modelStr !== args.target.modelStr || existing.provider !== args.target.provider) ) { throw new Error("Native Codex turn target changed after output was emitted"); } + // ConnectionId changes are allowed (failover to sibling connection) + // as long as provider + model stay the same. const now = Date.now(); pins.set(key, { comboName: args.comboName, @@ -92,21 +92,52 @@ export function pinNativeCodexTurn(args: { prune(now); } +/** + * Apply a native Codex turn pin to the target list. + * + * Returns all compatible targets (same provider + model) with the pinned + * connection preferred first. This allows fill-first failover: if the + * pinned connection is rejected by a pre-dispatch gate, the combo engine + * tries the next compatible connection instead of returning 503. + * + * Provider + model remain locked for the turn — only the connection + * can fall over. + */ export function applyNativeCodexTurnPin( targets: ResolvedComboTarget[], pin: NativeTurnPin ): ResolvedComboTarget[] { - const target = targets.find( + const compatible = targets.filter( (candidate) => candidate.modelStr === pin.modelStr && candidate.provider === pin.provider ); - if (!target) return []; - return [ - { - ...target, - connectionId: pin.connectionId, - allowedConnectionIds: [pin.connectionId], - }, - ]; + if (compatible.length === 0) return []; + + let pinnedIndex = compatible.findIndex((t) => t.connectionId === pin.connectionId); + // No candidate already carries the pinned connectionId (e.g. the caller + // resolved the target before a connection was assigned) — assign the pin + // onto the first compatible candidate so dispatch targets it directly. + if (pinnedIndex < 0) pinnedIndex = 0; + + // Resolve the pinned slot's connectionId in ORIGINAL order first, so + // allowedConnectionIds reflects the same set/order regardless of which + // candidate ends up first in the returned (pinned-first) array. + const resolved = compatible.map((t, i) => + i === pinnedIndex ? { ...t, connectionId: pin.connectionId } : t + ); + const allowedConnectionIds = resolved + .map((t) => t.connectionId) + .filter((id): id is string => id !== null); + + // Pinned connection first, then same-provider/model siblings as fallback + const pinned = resolved[pinnedIndex]; + const siblings = resolved.filter((_, i) => i !== pinnedIndex); + const ordered = [pinned, ...siblings]; + + return ordered.map((target) => ({ + ...target, + // Allow only connections for the pinned provider+model + allowedConnectionIds, + })); } export function revokeNativeCodexTurnPinsForConnection(connectionId: string): number { diff --git a/open-sse/services/combo/targetResolution.ts b/open-sse/services/combo/targetResolution.ts index d5af5a6e01..45aa57013f 100644 --- a/open-sse/services/combo/targetResolution.ts +++ b/open-sse/services/combo/targetResolution.ts @@ -115,6 +115,14 @@ export interface ResolveComboTargetPipelineDeps { hiddenModelsByProvider?: HiddenModelsByProvider; /** Native Responses clients (for example Codex CLI/Desktop) manage compaction themselves. */ clientManagedResponsesContext?: boolean; + /** #10225 — defer the hard context-overflow preflight when compression is enabled for this request. */ + deferContextOverflowWhenCompressible?: boolean; + /** Server-side compression exclusions (#8034) — which targets can run compression. */ + compressionExclusions?: import("../compression/exclusions.ts").CompressionExclusions; + /** #10503 — request-shape facts for the target-aware deferral check (see knownContextOverflow.ts). */ + sourceFormat?: string | null; + endpointPath?: string | null; + requestHeaders?: Headers | Record | null; } export interface ResolvedComboTargetPipeline { @@ -730,6 +738,11 @@ export async function resolveComboTargetPipeline( const overflow = getKnownContextOverflow(orderedTargets, body, { clientManagedResponsesContext: deps.clientManagedResponsesContext, + deferContextOverflowWhenCompressible: deps.deferContextOverflowWhenCompressible, + compressionExclusions: deps.compressionExclusions, + sourceFormat: deps.sourceFormat, + endpointPath: deps.endpointPath, + requestHeaders: deps.requestHeaders, }); if (overflow) { return { earlyResponse: buildContextOverflowResponse(overflow, orderedTargets, log) }; diff --git a/open-sse/services/combo/types.ts b/open-sse/services/combo/types.ts index 9f9f31c4b4..03349e43c7 100644 --- a/open-sse/services/combo/types.ts +++ b/open-sse/services/combo/types.ts @@ -6,7 +6,9 @@ * — logic unchanged, re-exported from combo.ts for backward compatibility. */ +import type { CompressionExclusions } from "../compression/exclusions.ts"; import type { ProviderCandidate } from "../autoCombo/scoring.ts"; +import type { PerTargetAdmissionHook } from "../admission/types.ts"; export const RESET_WINDOW_NAMES = ["weekly", "session", "monthly"] as const; @@ -112,6 +114,31 @@ export type HandleComboChatOptions = { hiddenModelsByProvider?: HiddenModelsByProvider; /** Native Responses clients (for example Codex CLI/Desktop) manage compaction themselves. */ clientManagedResponsesContext?: boolean; + /** + * #9654 Wave 2: per-target lane-aware admission probe for fan-out dispatch. + * Strictly non-blocking (maxWaitMs 0), no-op when virtual lanes are off, + * keyed to the parent's tenantKey. Skipped targets are not dispatched. + */ + perTargetAdmission?: PerTargetAdmissionHook | null; + /** + * #10225: request-scoped flag — prompt compression is enabled for this request + * (global compression switch ON and not opted-out by the API key). When set, the + * combo preflight defers its hard context-overflow rejection so chatCore's + * compression runs before the final context gate. + */ + deferContextOverflowWhenCompressible?: boolean; + /** Server-side compression exclusions (#8034) — used to check which targets can run compression. */ + compressionExclusions?: CompressionExclusions; + /** + * #10503: request-shape facts (mirroring chatCore.ts's own resolution) threaded + * down to getKnownContextOverflow so the deferral decision can be target-aware — + * a native-Codex-Responses-passthrough target must never count as "compressible" + * (chatCore disables compression for it unconditionally). See + * knownContextOverflow.ts::KnownContextOverflowOptions for the full rationale. + */ + sourceFormat?: string | null; + endpointPath?: string | null; + requestHeaders?: Headers | Record | null; }; export type HandleRoundRobinOptions = Omit; diff --git a/open-sse/services/compression/engines/omniglyphAdapter.ts b/open-sse/services/compression/engines/omniglyphAdapter.ts index 3f62d5febf..545c152672 100644 --- a/open-sse/services/compression/engines/omniglyphAdapter.ts +++ b/open-sse/services/compression/engines/omniglyphAdapter.ts @@ -117,15 +117,24 @@ async function applyOmniglyph( let outBody: Record; try { const encoded = new TextEncoder().encode(JSON.stringify(body)); - const result = - wireFormat === "claude" - ? await transformAnthropicMessages({ body: encoded, model }) - : wireFormat === "openai" + // Branch explicitly so TS narrows each transformer's return type: + // the Anthropic wrapper reports `applied`, the OpenAI ones `info.compressed`. + let applied: boolean; + let transformed: { body: Uint8Array; info: { compressed: boolean; reason?: string } }; + if (wireFormat === "claude") { + const result = await transformAnthropicMessages({ body: encoded, model }); + transformed = result; + applied = result.applied; + } else { + const result = + wireFormat === "openai" ? await transformOpenAIChatCompletions(encoded) : await transformOpenAIResponses(encoded); - const applied = wireFormat === "claude" ? result.applied : result.info.compressed; - if (!applied) return skip(body, result.info?.reason ?? "not_profitable"); - outBody = JSON.parse(new TextDecoder().decode(result.body)) as Record; + transformed = result; + applied = result.info.compressed; + } + if (!applied) return skip(body, transformed.info?.reason ?? "not_profitable"); + outBody = JSON.parse(new TextDecoder().decode(transformed.body)) as Record; } catch { // Fail-open: qualquer erro no encode/transform/decode (ex.: corpo não serializável, // render PNG estourando, JSON decodificado malformado) vira skip, nunca propaga. diff --git a/open-sse/services/compression/outputMode.ts b/open-sse/services/compression/outputMode.ts index 725e806e64..320ec41f92 100644 --- a/open-sse/services/compression/outputMode.ts +++ b/open-sse/services/compression/outputMode.ts @@ -64,6 +64,11 @@ export const CAVEMAN_INSTRUCTION_BY_LANGUAGE = { full: `Jawab sangat singkat ala caveman pintar. Hapus kata pengisi (hanya/sangat/sebenarnya), salam sopan santun. Kalimat pendek/tidak lengkap OK. Gunakan sinonim pendek. Pertahankan semua substansi teknis, kode, error, URL, & identifier secara persis. ${SHARED_BOUNDARIES}`, ultra: `Jawab ultra singkat. Kompresi maksimal. Gunakan singkatan umum (DB/auth/config/req/res/fn/impl), hilangkan kata hubung, gunakan panah untuk kausalitas (X → Y). Satu kata jika cukup. Jangan singkat simbol kode, nama API, string error, URL, atau identifier. ${SHARED_BOUNDARIES}`, }, + vi: { + lite: `Trả lời súc tích. Bỏ từ đệm, sáo rỗng, rào đón. Giữ nguyên câu hoàn chỉnh, thuật ngữ kỹ thuật, code, lỗi, URL và định danh. ${SHARED_BOUNDARIES}`, + full: `Trả lời cộc lốc như người tối cổ thông minh. Bỏ mạo từ, từ đệm, sáo rỗng, rào đón. Chấp nhận câu rút gọn. Dùng từ đồng nghĩa ngắn. Giữ nguyên mọi nội dung kỹ thuật, code, lỗi, URL và định danh. ${SHARED_BOUNDARIES}`, + ultra: `Trả lời cực kỳ cộc lốc. Nén tối đa. Như điện tín. Viết tắt (DB/auth/config/req/res/fn/impl), bỏ liên từ, dùng mũi tên cho quan hệ nhân quả (X → Y). Một từ nếu một từ là đủ. Không bao giờ viết tắt ký hiệu code, tên API, chuỗi lỗi, URL hoặc định danh. ${SHARED_BOUNDARIES}`, + }, } as const; const CAVEMAN_OUTPUT_MARKER = "[OmniRoute Caveman Output Mode]"; diff --git a/open-sse/services/compression/outputStyles/catalog.ts b/open-sse/services/compression/outputStyles/catalog.ts index 800d099fd0..e65590b65f 100644 --- a/open-sse/services/compression/outputStyles/catalog.ts +++ b/open-sse/services/compression/outputStyles/catalog.ts @@ -41,6 +41,7 @@ export const OUTPUT_STYLE_CATALOG: Record = { "pt-BR": CAVEMAN_INSTRUCTION_BY_LANGUAGE["pt-BR"], ja: CAVEMAN_INSTRUCTION_BY_LANGUAGE.ja, id: CAVEMAN_INSTRUCTION_BY_LANGUAGE.id, + vi: CAVEMAN_INSTRUCTION_BY_LANGUAGE.vi, }, }, "less-code": { @@ -53,6 +54,28 @@ export const OUTPUT_STYLE_CATALOG: Record = { full: `Act like a lazy senior dev applying YAGNI. Smallest working change only. No unrequested abstractions, no premature generalization, no extra layers, no defensive scaffolding the request did not ask for. Reuse existing code over adding new code. ${SHARED_BOUNDARIES}`, ultra: `Minimal diff discipline. Touch the fewest lines that make it work. Zero new files, classes, or config unless strictly required. Inline over abstract. No "while we're here" extras. ${SHARED_BOUNDARIES}`, }, + i18n: { + "pt-BR": { + lite: `Escreva a menor alteração que satisfaça o pedido. Pule abstrações especulativas. ${SHARED_BOUNDARIES}`, + full: `Aja como um dev sênior preguiçoso aplicando YAGNI. Apenas a menor alteração funcional. Nenhuma abstração não solicitada, generalização prematura, camadas extras ou estrutura defensiva não pedida. Reutilize código existente em vez de adicionar novo. ${SHARED_BOUNDARIES}`, + ultra: `Disciplina de diff mínimo. Toque no menor número de linhas para funcionar. Zero arquivos, classes ou configs novos a menos que estritamente necessário. Inline em vez de abstrair. Sem extras "já que estamos aqui". ${SHARED_BOUNDARIES}`, + }, + vi: { + lite: `Viết thay đổi nhỏ nhất đáp ứng yêu cầu. Bỏ qua các abstraction suy đoán. ${SHARED_BOUNDARIES}`, + full: `Hành động như một senior dev lười biếng áp dụng YAGNI. Chỉ làm thay đổi nhỏ nhất chạy được. Không abstraction không được yêu cầu, không tổng quát hóa sớm, không thêm layer, không dàn giáo phòng thủ mà yêu cầu không hỏi. Dùng lại code có sẵn thay vì thêm code mới. ${SHARED_BOUNDARIES}`, + ultra: `Kỷ luật diff tối thiểu. Chạm ít dòng nhất để chạy được. Không file, class hay config mới trừ khi bắt buộc. Inline thay vì abstract. Không thêm thắt kiểu "tiện tay làm luôn". ${SHARED_BOUNDARIES}`, + }, + ja: { + lite: `要求を満たす最小の変更を書け。推測に基づく抽象化はスキップ。${SHARED_BOUNDARIES}`, + full: `YAGNIを適用する怠惰なシニア開発者のように振る舞え。動く最小の変更のみ。要求されていない抽象化、時期尚早な汎用化、余分なレイヤー、要求されていない防御的足場は禁止。新規コード追加より既存コードの再利用。${SHARED_BOUNDARIES}`, + ultra: `最小diffの規律。動くようにするための変更行数を最小に。厳密に必要でない限り、新規ファイル、クラス、設定はゼロ。抽象化よりインライン。ついでに行う余分な変更は禁止。${SHARED_BOUNDARIES}`, + }, + id: { + lite: `Tulis perubahan terkecil yang memenuhi permintaan. Lewati abstraksi spekulatif. ${SHARED_BOUNDARIES}`, + full: `Bertindak seperti dev senior malas yang menerapkan YAGNI. Hanya perubahan terkecil yang berfungsi. Tanpa abstraksi yang tidak diminta, generalisasi prematur, lapisan ekstra, atau scaffolding defensif yang tidak diminta. Pakai ulang kode yang ada daripada menambah kode baru. ${SHARED_BOUNDARIES}`, + ultra: `Disiplin diff minimal. Sentuh baris sesedikit mungkin yang membuatnya berfungsi. Nol file, kelas, atau config baru kecuali sangat diperlukan. Inline daripada abstract. Tanpa tambahan "mumpung di sini". ${SHARED_BOUNDARIES}`, + }, + }, }, // Ponytail (lazy-senior-dev mode) — integrated into the output-style registry // so it rides the existing production injector instead of a bespoke module. diff --git a/open-sse/services/conversationTracker.ts b/open-sse/services/conversationTracker.ts new file mode 100644 index 0000000000..cd70593fd2 --- /dev/null +++ b/open-sse/services/conversationTracker.ts @@ -0,0 +1,482 @@ +/** + * Conversation Tracker — assigns a stable conversation id across separate + * HTTP requests that are turns of the same multi-turn agentic conversation. + * + * Clients resend the full growing message/input history on every turn (no + * server-side state dependency). Continuation is detected with a per-turn + * hash chain (each turn's id = sha256(parentId, role, sha256(text)), the + * same idea as a git commit graph): a new request's turns are walked from + * the start against the candidate conversation's existing chain, matching as + * far as they agree. Real agentic-CLI traffic (OpenClaw and similar) often + * edits or duplicates a turn mid-history to keep provider-side prompt caches + * warm — e.g. request 1 has turns `a b c … h i`, request 2 has + * `a b c′ … h i′ i j k`. A whole-history hash (the original approach) breaks + * on any such edit and never reconnects. + * + * Every OmniRoute conversation is a single straight line — it never forks. + * When a turn diverges from what's already on file (`c` became `c'`), that + * diverging history becomes its OWN independent conversation, with its own + * id, built fresh from this request's full turn list — not a branch grafted + * onto the old chain (2026-08-06 redesign; the branching model's real + * traffic accumulated dozens of edits per session, and indenting one more + * tree level per edit eventually left no room to show content at all). + * `a b c d` and `a b c' d'` end up as two distinct conversations, sharing no + * further storage after the point they diverge — simpler to store, query, + * and render than a tree, and it matches how the data is actually used: a + * "conversation" here is one continuous transcript, not a version-control + * graph. This is a new, persisted mechanism — separate from + * `sessionManager.ts`'s `generateSessionId()` (in-memory, routing/latency + * only) even though it uses the same sha256-fingerprint style. + * + * @see Issue: X-ConversationId / agentic conversation tracking + */ + +import { createHash, randomUUID } from "node:crypto"; +import { + createAgenticConversation, + findAgenticConversationsByFingerprint, + getConversationTurnIndex, + insertConversationTurnNodes, + touchOrCreateExternalConversation, + updateAgenticConversation, + type ConversationTurnIndex, +} from "../../src/lib/db/agenticConversations.ts"; + +type JsonRecord = Record; + +interface CanonicalTurn { + role: "system" | "user" | "assistant" | "tool"; + text: string; + /** 'text' | 'tool_use' | 'tool_result' — carried through to + * conversation_turn_nodes so the tree view (and any other consumer) can + * build the exact NormalizedBlock (src/mitm/inspector/types.ts) the + * request-detail panel already builds from buildRequestTurns/ + * buildResponseTurns, rendering tool calls/results through the same + * ChatBubble/MessageContent/ToolCallBlock/ToolResultBlock components + * everywhere instead of a parallel tree-only implementation. */ + blockKind: "text" | "tool_use" | "tool_result"; + /** Set only when blockKind === "tool_use". */ + toolName: string | null; +} + +export interface ResolveConversationIdInput { + body: JsonRecord | null | undefined; + model: string | null; + apiKeyId: string | null; + /** Raw `x-omniroute-session-id` header value, if the client supplied one. */ + clientSessionIdHeader: string | null; + /** + * call_logs.correlation_id for this request (109_call_logs_correlation_id) + * — generated earlier in the request lifecycle, well before this request's + * own call_logs row/id exists, so it's the only stable identifier + * available here to tag new turn nodes with. The tree API route + * (src/app/api/conversations/[id]/tree/route.ts) joins through it to + * resolve a navigable call_logs.id. + */ + correlationId: string | null; +} + +export interface ResolveConversationIdResult { + conversationId: string; + isNewConversation: boolean; +} + +// ── Canonicalization ───────────────────────────────────────────────────── + +function normalizeRole(raw: unknown): CanonicalTurn["role"] { + if (raw === "system" || raw === "user" || raw === "assistant" || raw === "tool") return raw; + if (raw === "developer") return "system"; + if (raw === "model") return "assistant"; + if (raw === "function") return "tool"; + return "user"; +} + +/** + * Extract human-readable text from an OpenAI/Anthropic/Responses-API + * `content` value. Chat Completions sends a plain string; Responses API and + * Anthropic send an array of typed blocks (`{type:"text"|"input_text"| + * "output_text", text}`, `tool_use`, `tool_result`, ...) — collapsing that + * array to its text (rather than `JSON.stringify`-ing the whole thing) is + * what feeds both the turn-hash-chain (so the same underlying text chains + * identically regardless of which block-array shape a client used to send + * it) and `text_preview`, which the /dashboard/conversations tree view + * renders directly as markdown — a raw JSON blob there was a real bug, not a + * cosmetic one. + */ +function stringifyContent(content: unknown): string { + if (typeof content === "string") return content; + if (content == null) return ""; + if (Array.isArray(content)) { + const parts: string[] = []; + for (const item of content) { + if (typeof item === "string") { + parts.push(item); + continue; + } + const block = item && typeof item === "object" ? (item as JsonRecord) : null; + if (!block) continue; + const type = block.type; + if ( + (type === "text" || type === "input_text" || type === "output_text") && + typeof block.text === "string" + ) { + parts.push(block.text); + } else if (type === "tool_use" || type === "function_call") { + const name = typeof block.name === "string" ? block.name : ""; + parts.push(`[tool_use ${name}]`); + } else if (type === "tool_result" || type === "function_call_output") { + parts.push(stringifyContent(block.content ?? block.output ?? "")); + } else if (typeof block.text === "string") { + parts.push(block.text); + } + } + return parts.join("\n"); + } + try { + return JSON.stringify(content); + } catch { + return ""; + } +} + +/** + * Flatten a Chat Completions `messages[]` array or a Responses API `input` + * (array, bare string, or single message-shaped object) into a stable, + * format-agnostic turn list. Ignores ids/tool_call_ids/metadata entirely — + * only role + a string projection of content survive, since those are the + * only fields that stay stable across a client's own re-encoding of history. + */ +export function extractCanonicalTurns(body: JsonRecord | null | undefined): CanonicalTurn[] { + if (!body || typeof body !== "object") return []; + + let raw: unknown[]; + if (Array.isArray(body.messages)) { + raw = body.messages; + } else if (Array.isArray(body.input)) { + raw = body.input; + } else if (typeof body.input === "string") { + raw = [{ role: "user", content: body.input }]; + } else if (body.input && typeof body.input === "object") { + raw = [body.input]; + } else { + raw = []; + } + + const turns: CanonicalTurn[] = []; + for (const item of raw) { + const rec = item && typeof item === "object" ? (item as JsonRecord) : {}; + // Responses API function_call/function_call_output items have no `role` + // but do carry stable identifying text — fold them in as "tool" turns so + // tool round-trips still contribute to the continuation signal. + const role = rec.role + ? normalizeRole(rec.role) + : rec.type === "function_call" || rec.type === "function_call_output" + ? "tool" + : null; + if (!role) continue; + const text = stringifyContent(rec.content ?? rec.text ?? rec.arguments ?? rec.output); + if (!text) continue; + + // Chat Completions tool-result messages (role: "tool"/"function") and + // Responses API function_call/function_call_output items are the only + // two shapes this canonicalizer sees for tool activity — everything + // else (including plain assistant/user/system text) is "text". + let blockKind: CanonicalTurn["blockKind"] = "text"; + let toolName: string | null = null; + if (rec.type === "function_call") { + blockKind = "tool_use"; + toolName = typeof rec.name === "string" ? rec.name : null; + } else if (rec.type === "function_call_output") { + blockKind = "tool_result"; + } else if (rec.role === "tool" || rec.role === "function") { + blockKind = "tool_result"; + toolName = typeof rec.name === "string" ? rec.name : null; + } + + turns.push({ role, text, blockKind, toolName }); + } + return turns; +} + +// ── Fingerprint (identity, O(1) regardless of history size) ───────────── + +function hashHex(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +function extractToolNames(body: JsonRecord | null | undefined): string[] { + if (!body || !Array.isArray(body.tools)) return []; + const names: string[] = []; + for (const tool of body.tools as unknown[]) { + const rec = tool && typeof tool === "object" ? (tool as JsonRecord) : {}; + const fn = rec.function && typeof rec.function === "object" ? (rec.function as JsonRecord) : {}; + const name = + typeof rec.name === "string" ? rec.name : typeof fn.name === "string" ? fn.name : ""; + if (name) names.push(name); + } + return names.sort(); +} + +// Deliberately excludes any message text — both the system prompt (real +// coding-agent CLIs like Claude Code/opencode regenerate it every request +// with live context: timestamp, cwd, git status...) AND, discovered live on +// a real OmniRoute deployment running OpenClaw, the first non-system turn +// too: OpenClaw's sliding context window drops/summarizes the EARLIEST +// turns as a session grows, so `firstNonSystemText` never stays stable +// across requests either — anchoring identity to either one mints a brand +// new conversation (or, worse, finds zero fingerprint candidates at all, so +// the turn-chain match in resolveConversationId never even runs) on every +// single turn for exactly this kind of real traffic, even though the actual +// history is a genuine, unbroken continuation. The bucket only needs to be +// small enough to bound candidate lookup — apiKeyId + model + toolNames is +// stable across a whole session and still narrow in practice; actual +// identity is decided by the turn-chain walk (real content overlap), not by +// this bucket, so widening it here cannot cause a false merge on its own. +export function computeFingerprintHash(input: { + apiKeyId: string | null; + model: string | null; + toolNames: string[]; +}): string { + const parts = [input.apiKeyId ?? "", input.model ?? "", input.toolNames.join(",")]; + // NOTE: no connectionId — conversation identity must not depend on which + // upstream connection this particular turn happened to be routed to. + return hashHex(parts.join("|")); +} + +// ── Turn hash chain (continuation + branch detection) ──────────────────── +// +// Each turn gets a stable id chained to its predecessor, the same idea as a +// git commit graph: id = sha256(parentId, role, sha256(text)). A brand-new +// tree's first turn chains off the conversation root id itself (not off +// `null`) so two different, unrelated conversation trees whose first turn +// happens to be byte-identical (e.g. two sessions that both open with "hi") +// never compute the same node id — `conversation_turn_nodes.id` is a global +// primary key, not scoped per conversation_id. +// +// Nodes store identity only (id/parent/content_hash), never the turn's +// actual text/tool-call shape — the dashboard resolves that on demand from +// the call-log pipeline artifact each node's correlation id points at (see +// conversationTurnContent.ts), re-running extractCanonicalTurns over that +// artifact's full, untruncated request body and matching by contentHash. +// Exported so that resolver can compute the same hash for a lookup key. +export function hashTurnContent(turn: CanonicalTurn): string { + return hashHex(`${turn.role} ${turn.text}`); +} + +function chainNodeId(parentId: string, turn: CanonicalTurn): string { + return hashHex(`${parentId} ${hashTurnContent(turn)}`); +} + +interface NewTurnNode { + id: string; + parentId: string | null; + role: string; + contentHash: string; +} + +/** Build the new-node run for turns[fromIndex:], chained off `chainAnchor`. */ +function buildNewNodes( + turns: CanonicalTurn[], + fromIndex: number, + chainAnchor: string, + rootId: string +): NewTurnNode[] { + const nodes: NewTurnNode[] = []; + let parent = chainAnchor; + for (let i = fromIndex; i < turns.length; i++) { + const turn = turns[i]; + const nodeId = chainNodeId(parent, turn); + nodes.push({ + id: nodeId, + // The root anchor is a hashing seed, not a real node — the first turn + // of a tree has no parent turn. + parentId: parent === rootId ? null : parent, + role: turn.role, + contentHash: hashTurnContent(turn), + }); + parent = nodeId; + } + return nodes; +} + +interface ReconnectMatch { + /** Index into `chainTurns` where the reconnection was found (turns before + * this index were dropped from the chain's view — a compacted summary the + * client sent instead of resending them verbatim — and are not inserted + * as nodes). */ + startIndex: number; + /** How far the match extends past startIndex (>= startIndex + 1). */ + matchEndIndex: number; + /** Node id to chain new nodes off (the last matched node). */ + anchorNodeId: string; + /** True when `anchorNodeId` already has a recorded child in this chain — + * i.e. turns[matchEndIndex] (if any) would collide with an existing, + * DIFFERENT turn rather than simply being new. See resolveConversationId's + * doc comment for what this distinction now controls. */ + anchorHasChild: boolean; +} + +/** + * Find where `chainTurns` reconnects to an existing chain, trying the + * leftmost turn first (so a still-fully-present prefix — the common case — + * matches immediately at the start) and falling back to later turns only + * when earlier ones aren't found anywhere in the chain. This is what makes + * continuation detection survive OpenClaw's sliding context window: once + * the earliest turns are compacted away, turn 0 of a new request is some + * turn from the MIDDLE of the existing chain, not its start — a start-only + * walk (checking only whether turn 0 is the chain's own first turn) would + * find nothing. + * + * Real agentic traffic is full of byte-identical repeated turns — a tool + * polling loop's "Process still running." output, a heartbeat ack, a + * one-word "ok" — so `byContentHash.get(...)` routinely returns MANY + * candidate anchors for the same turn (one real conversation observed 28 + * duplicates of a single OpenClaw runtime-context turn). Evaluating only the + * first candidate (as this used to do) meant returning whichever occurrence + * SQLite happened to list first — in practice the OLDEST, most stale one — + * whose recorded next-turn almost never matches the current request, so the + * walk stalled a few turns in and (worse) that stale anchor already has a + * DIFFERENT recorded child, tripping `anchorHasChild` and making + * resolveConversationId treat a genuine continuation as a divergence. Live + * result: a real conversation minted a brand-new copy of its ENTIRE history + * on every single request instead of ever reconnecting (2026-08-06). Every + * candidate anchor for every prefix start is now tried, and the one that + * verifiably extends furthest into the actual request wins — the only + * reliable signal of genuine continuation when content repeats. + */ +function findReconnectMatch( + chainTurns: CanonicalTurn[], + index: ConversationTurnIndex +): ReconnectMatch | null { + let best: ReconnectMatch | null = null; + + for (let s = 0; s < chainTurns.length; s++) { + const anchors = index.byContentHash.get(hashTurnContent(chainTurns[s])); + if (!anchors) continue; + for (const anchorNodeId of anchors) { + let parent = anchorNodeId; + let matchEndIndex = s + 1; + for (let i = s + 1; i < chainTurns.length; i++) { + const nodeId = chainNodeId(parent, chainTurns[i]); + if (!index.nodeIds.has(nodeId)) break; + parent = nodeId; + matchEndIndex++; + } + const anchorHasChild = index.parentsWithChildren.has(parent); + // Longest verified run wins outright. An equal-length run breaks + // toward anchorHasChild===false: a tie means both candidate anchors' + // recorded next-turn already differs from what's being requested (the + // walk stopped for the same reason on both), so the anchor with NO + // established child is the safe, unambiguous "just append here" — the + // other, having a different recorded child already, would incorrectly + // read as a divergence purely because it happened to be tried first. + const isBetter = + !best || + matchEndIndex > best.matchEndIndex || + (matchEndIndex === best.matchEndIndex && !anchorHasChild && best.anchorHasChild); + if (isBetter) { + best = { startIndex: s, matchEndIndex, anchorNodeId: parent, anchorHasChild }; + } + // Can't do better than matching every turn through to the end. + if (matchEndIndex === chainTurns.length) return best; + } + } + return best; +} + +// ── Orchestration ───────────────────────────────────────────────────────── + +const MAX_STORED_ID_LENGTH = 128; + +export async function resolveConversationId( + input: ResolveConversationIdInput +): Promise { + // Client override wins outright — deterministic, zero heuristic risk. + // Same header feature #8249 already reads (chatCore.ts); we don't invent a + // new prefix so the existing header's contract/format stays unchanged. + if (input.clientSessionIdHeader && input.clientSessionIdHeader.trim()) { + const id = input.clientSessionIdHeader.trim().slice(0, MAX_STORED_ID_LENGTH); + touchOrCreateExternalConversation(id, { apiKeyId: input.apiKeyId }); + return { conversationId: id, isNewConversation: false }; + } + + const turns = extractCanonicalTurns(input.body); + const toolNames = extractToolNames(input.body); + const fingerprintHash = computeFingerprintHash({ + apiKeyId: input.apiKeyId, + model: input.model, + toolNames, + }); + + // The turn CHAIN excludes the system message entirely, same reasoning as + // extractFirstNonSystemText above: real coding-agent CLIs regenerate the + // system prompt (timestamp/cwd/git status...) on every single request, so + // treating it as an ordinary chained turn would make turn-0 (or wherever + // it sits) fail to match on every request — reintroducing the exact + // always-new-conversation bug this chain design exists to fix. + const chainTurns = turns.filter((t) => t.role !== "system"); + + const candidates = findAgenticConversationsByFingerprint(fingerprintHash); + for (const candidate of candidates) { + const index = getConversationTurnIndex(candidate.id); + if (index.nodeIds.size === 0) continue; + + const match = findReconnectMatch(chainTurns, index); + // No match anywhere in the chain means this candidate isn't actually + // this conversation's lineage — it only shares the coarse fingerprint + // bucket (apiKeyId/model/toolNames), which real traffic proves is not + // enough to assume overlap on its own (see computeFingerprintHash's doc + // comment) — try the next candidate rather than attaching a completely + // unrelated turn. + if (!match) continue; + + if (match.matchEndIndex === chainTurns.length) { + // Every turn from the reconnect point onward already exists on this + // chain (e.g. an exact retry, or the whole request is already fully + // recorded) — a real continuation, nothing new to insert. + updateAgenticConversation(candidate.id, { turnCount: candidate.turnCount + 1 }); + return { conversationId: candidate.id, isNewConversation: false }; + } + + if (!match.anchorHasChild) { + // Genuine tail growth: the reconnect point has no recorded child yet, + // so turns[matchEndIndex:] are simply turns this conversation hasn't + // seen before — append them to this SAME chain. Turns before + // startIndex (a compacted-away prefix, if any) are never inserted — + // they don't represent new content, just the client's own context + // management. + const newNodes = buildNewNodes( + chainTurns, + match.matchEndIndex, + match.anchorNodeId, + candidate.id + ); + insertConversationTurnNodes(candidate.id, input.correlationId, newNodes); + updateAgenticConversation(candidate.id, { turnCount: candidate.turnCount + 1 }); + return { conversationId: candidate.id, isNewConversation: false }; + } + + // The reconnect point already has a DIFFERENT recorded child — this + // request's turn at that position diverges from what's on file (a real + // OpenClaw cache-aware-context edit: turn `c` became `c'`). As of the + // 2026-08-06 redesign, an edited/duplicated turn no longer forks a + // branch inside this conversation's own chain — every OmniRoute + // conversation is now a single straight line, never a tree. The + // diverging history becomes its own independent conversation instead + // (built fresh below, from this request's full turn list) — distinct + // conversation ids for `a b c d` and `a b c' d'`, not one tree with two + // branches. This is both simpler to store/query and fixes a real UX + // problem the branching model had: real OpenClaw traffic accumulates + // dozens of edits per session, and indenting one more level per fork + // eventually left no horizontal space for content at all. Keep checking + // remaining candidates first, though — a later candidate may already BE + // that independent conversation from a previous edit at this same spot + // (e.g. a repeated retry of the edited turn), which should continue + // that one rather than minting yet another new id for it. + } + + const id = `conv_${randomUUID()}`; + createAgenticConversation({ id, apiKeyId: input.apiKeyId, fingerprintHash }); + insertConversationTurnNodes(id, input.correlationId, buildNewNodes(chainTurns, 0, id, id)); + return { conversationId: id, isNewConversation: true }; +} diff --git a/open-sse/services/conversationTurnContent.ts b/open-sse/services/conversationTurnContent.ts new file mode 100644 index 0000000000..a95a39c939 --- /dev/null +++ b/open-sse/services/conversationTurnContent.ts @@ -0,0 +1,82 @@ +/** + * conversationTurnContent.ts — resolves a conversation_turn_nodes row's + * actual display text/tool-call shape on demand, instead of storing it. + * + * conversation_turn_nodes (migration 156) is identity-only: id/parent/ + * content_hash, no turn text. Every node's originating request is already + * fully captured by the call-log pipeline artifact its `last_correlation_id` + * points at (call_logs.artifact_relpath, behind call_log_pipeline_enabled), + * so display content is re-derived from there on read instead of duplicating + * it into a second store: load the artifact's raw client request body, run + * it back through the SAME extractCanonicalTurns/hashTurnContent the write + * path used, and match by content_hash. This also gives full, untruncated + * text where the old stored text_preview was capped at 8000 chars. + */ + +import { getDbInstance } from "../../src/lib/db/core.ts"; +import { readCallArtifact } from "../../src/lib/usage/callLogArtifacts.ts"; +import { extractCanonicalTurns, hashTurnContent } from "./conversationTracker.ts"; + +export type TurnDisplayContent = { + textPreview: string; + blockKind: "text" | "tool_use" | "tool_result"; + toolName: string | null; +}; + +/** + * Resolve display content for a batch of turn nodes, keyed by content_hash. + * Content_hash is sha256(role+text) only — real traffic has plenty of + * byte-identical repeated turns (a tool-polling "still running" ack), so + * distinct nodes legitimately share one hash; since the hash is exactly the + * display text's own identity, resolving once per unique hash is correct, + * not lossy, and avoids redundant artifact reads for a request that touched + * many nodes at once. + */ +export function resolveTurnDisplayContent( + nodes: ReadonlyArray<{ lastCorrelationId: string | null }> +): Map { + const result = new Map(); + const correlationIds = [ + ...new Set(nodes.map((n) => n.lastCorrelationId).filter((v): v is string => !!v)), + ]; + if (correlationIds.length === 0) return result; + + const db = getDbInstance(); + const placeholders = correlationIds.map(() => "?").join(","); + const rows = db + .prepare( + `SELECT correlation_id, artifact_relpath FROM call_logs + WHERE correlation_id IN (${placeholders}) AND artifact_relpath IS NOT NULL + ORDER BY timestamp ASC` + ) + .all(...correlationIds) as Array<{ correlation_id: string; artifact_relpath: string }>; + + // A retry/combo-fallback attempt can share one correlation_id across a few + // call_logs rows; they all carry the same client-facing request body, so + // any one artifact is a valid content source — keep the first. + const artifactPathByCorrelationId = new Map(); + for (const row of rows) { + if (!artifactPathByCorrelationId.has(row.correlation_id)) { + artifactPathByCorrelationId.set(row.correlation_id, row.artifact_relpath); + } + } + + for (const relPath of artifactPathByCorrelationId.values()) { + const { artifact, state } = readCallArtifact(relPath); + if (state !== "ready") continue; + const clientRawRequest = artifact?.pipeline?.clientRawRequest as { body?: unknown } | undefined; + const body = clientRawRequest?.body; + if (!body || typeof body !== "object") continue; + + for (const turn of extractCanonicalTurns(body as Record)) { + const hash = hashTurnContent(turn); + if (result.has(hash)) continue; + result.set(hash, { + textPreview: turn.text, + blockKind: turn.blockKind, + toolName: turn.toolName, + }); + } + } + return result; +} diff --git a/open-sse/services/fusion.ts b/open-sse/services/fusion.ts index 767da61531..7382f38ece 100644 --- a/open-sse/services/fusion.ts +++ b/open-sse/services/fusion.ts @@ -20,6 +20,7 @@ */ import { errorResponse, sanitizeErrorMessage } from "../utils/error.ts"; import { extractTextContent } from "../translator/helpers/geminiHelper.ts"; +import type { PerTargetAdmissionHook } from "./admission/types.ts"; import type { ComboLogger, HandleSingleModel, ResolvedComboTarget } from "./combo/types.ts"; // Fusion tuning. Overridable per-combo via combo.config.fusionTuning. @@ -72,8 +73,7 @@ export function extractPanelText(json: unknown): string { // Gemini (parts carry .text without a type discriminator) const candidates = j.candidates as Array> | undefined; const parts = (candidates?.[0]?.content as Record | undefined)?.parts as - | Array<{ text?: unknown }> - | undefined; + Array<{ text?: unknown }> | undefined; if (Array.isArray(parts)) { const t = parts.map((p) => (typeof p?.text === "string" ? p.text : "")).join(""); if (t.trim()) return t; @@ -229,6 +229,8 @@ export type HandleFusionChatOptions = { judgeModel?: string | null; judgeTarget?: ResolvedComboTarget | null; tuning?: FusionTuning | null; + /** #9654 Wave 2: per-target lane-aware admission probe (see HandleComboChatOptions). */ + perTargetAdmission?: PerTargetAdmissionHook | null; }; function getFusionModelString(model: FusionModel): string { @@ -273,6 +275,7 @@ export async function handleFusionChat({ judgeModel, judgeTarget, tuning, + perTargetAdmission, }: HandleFusionChatOptions): Promise { const panel = Array.isArray(models) ? models.filter(Boolean) : []; if (panel.length === 0) { @@ -304,14 +307,57 @@ export async function handleFusionChat({ stragglerGraceMs: tuning?.stragglerGraceMs ?? FUSION_DEFAULTS.stragglerGraceMs, panelHardTimeoutMs: tuning?.panelHardTimeoutMs ?? FUSION_DEFAULTS.panelHardTimeoutMs, }; + // Tools-stripped panel body (we want prose from panel members) — computed + // early so the per-target probe can estimate cost from the real fan-out body. + const { tools: _tools, tool_choice: _tc, ...rest } = body; + void _tools; + void _tc; + const panelBody: Body = { ...rest, stream: false }; + // #9654 Wave 2: per-target lane-aware admission probe — drop lane-full panel + // members before fan-out (strictly non-blocking; no-op when lanes off). See + // createPerTargetAdmissionHook for the full contract. Runs BEFORE minPanel / + // judge selection so quorum and the judge fallback only consider survivors. + let panelToDispatch = panel; + if (perTargetAdmission) { + const gates = await Promise.all( + panel.map(async (target) => ({ + target, + ok: await perTargetAdmission({ + modelStr: getFusionModelString(target), + executionKey: typeof target === "string" ? target : target.executionKey, + body: panelBody, + }), + })) + ); + const dropped = gates.filter((g) => !g.ok); + if (dropped.length > 0) { + log.info( + "FUSION", + `Skipping ${dropped.length} panel member(s) — admission lane full: ${dropped + .map((g) => getFusionModelString(g.target)) + .join(", ")}` + ); + } + panelToDispatch = gates.filter((g) => g.ok).map((g) => g.target); + if (panelToDispatch.length === 0) { + log.warn("FUSION", "All panel members skipped by admission lanes — nothing to fan out"); + return errorResponse(503, "All fusion panel members were skipped by admission lanes"); + } + } // Honor user-supplied minPanel down to 1: with 1 survivor we still degrade // gracefully via the answers.length===1 branch below (issue #6454). - const minPanel = Math.min(Math.max(1, cfg.minPanel), panel.length); + const minPanel = Math.min(Math.max(1, cfg.minPanel), panelToDispatch.length); const hasExplicitJudge = Boolean(judgeModel && judgeModel.trim()); - const judge = hasExplicitJudge ? (judgeModel as string).trim() : getFusionModelString(panel[0]); + // Judge fallback prefers the first SURVIVING panel member — a lane-full + // member dropped by the probe is never selected as the synthesis judge. + const judge = hasExplicitJudge + ? (judgeModel as string).trim() + : getFusionModelString(panelToDispatch[0]); log.info( "FUSION", - `Combo "${comboName ?? ""}" | panel=${panel.length} [${panel.map(getFusionModelString).join(", ")}] | judge=${judge} | quorum=${minPanel}` + `Combo "${comboName ?? ""}" | panel=${panelToDispatch.length} [${panelToDispatch + .map(getFusionModelString) + .join(", ")}] | judge=${judge} | quorum=${minPanel}` ); // Tool-bearing requests get no value from panel synthesis — panel members @@ -328,13 +374,8 @@ export async function handleFusionChat({ return handleSingleModel(body, judge); } - // 1. Fan out to the panel in parallel: non-streaming, tools stripped (we want prose). - const { tools: _tools, tool_choice: _tc, ...rest } = body; - void _tools; - void _tc; - const panelBody: Body = { ...rest, stream: false }; const t0 = Date.now(); - const calls = panel.map((target) => + const calls = panelToDispatch.map((target) => withTimeout(dispatchFusionModel(handleSingleModel, panelBody, target), cfg.panelHardTimeoutMs) ); const settled = await collectPanel(calls, { ...cfg, minPanel }); @@ -345,7 +386,7 @@ export async function handleFusionChat({ const failures: Array<{ model: string; reason: string }> = []; for (let i = 0; i < settled.length; i++) { const res = settled[i]; - const model = getFusionModelString(panel[i]); + const model = getFusionModelString(panelToDispatch[i]); if (!res) { log.warn("FUSION", `Panel ${model} dropped (straggler/timeout)`); failures.push({ model, reason: "straggler_dropped" }); diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 9465529a27..9df7d14aaf 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -645,6 +645,27 @@ async function resolveModelByProviderInference(modelId: string, extendedContext: } } + // Opencode free-tier models always route to opencode when active — prevents + // prefix inference from misrouting -free names to other providers when the + // live catalog is temporarily unreachable. + // + // A literal `activeProviders?.has("opencode")` check is unreachable in + // practice: `getActiveProviderSet()` canonicalizes every connection's + // provider id through `resolveProviderAlias()`, and the manual override + // above (`ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"`) rewrites any + // "opencode" id to "opencode-zen" before it ever reaches the active set — + // so an active no-auth opencode connection never appears as "opencode". + // Check both opencode-family canonical ids that catalog this model id. + if (modelId === "big-pickle" || modelId.endsWith("-free")) { + const candidates = MODEL_TO_PROVIDERS.get(modelId) || []; + const activeOpencodeCandidate = candidates.find( + (p) => (p === "opencode" || p === "opencode-zen") && activeProviders?.has(p) + ); + if (activeOpencodeCandidate) { + return { provider: activeOpencodeCandidate, model: modelId, extendedContext }; + } + } + const candidateProviders = getInferredProvidersForModel(modelId, activeSyncedProviders); const { providers, excludedProviders } = await reconcileInferredProvidersWithActiveCatalog( candidateProviders, diff --git a/open-sse/services/modelDeprecation.ts b/open-sse/services/modelDeprecation.ts index 4f38d68854..90ac077b1d 100644 --- a/open-sse/services/modelDeprecation.ts +++ b/open-sse/services/modelDeprecation.ts @@ -40,6 +40,13 @@ const BUILT_IN_ALIASES: Record = { "fireworks/accounts/fireworks/models/kimi-k2": "moonshotai/Kimi-K2", "kimi-k2": "moonshotai/Kimi-K2", + // Qwen — the model ships only under the `-preview` id (bailian-coding-plan, qoder, + // qwen-cloud-token-plan, qwen-web). Without this, the bare id missed MODEL_SPECS and + // the context preflight fell back to contextManager's `default: 128000`, rejecting + // prompts the model's real 1M window accepts. Drop this line if Alibaba ever ships a + // distinct GA `qwen3.8-max` — it would no longer be the same model. + "qwen3.8-max": "qwen3.8-max-preview", + // Mistral short aliases "mistral-large": "mistral-large-latest", "mistral-small": "mistral-small-latest", diff --git a/open-sse/services/requestDedup.ts b/open-sse/services/requestDedup.ts index 5ccde19529..1a39216197 100644 --- a/open-sse/services/requestDedup.ts +++ b/open-sse/services/requestDedup.ts @@ -32,16 +32,110 @@ export interface DedupResult { const inflight = new Map>(); +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * Extract the prompt-bearing content from a (possibly translated) request body. + * + * The prompt content lives under different keys depending on the target + * provider format the body has already been translated to: + * - OpenAI-style bodies (`open-sse/translator/request/*-to-openai.ts`, + * `openai-to-cursor.ts`): `messages` + * - Gemini-translated bodies (`openai-to-gemini.ts`, + * `claude-to-gemini.ts`): `contents` + * - Responses-API-translated bodies (`openai-responses/toResponses.ts`): + * `input` + * - Antigravity-translated bodies (`openai-to-gemini.ts` + * `openaiToAntigravityRequest` / `wrapInCloudCodeEnvelope`): nested under + * `request.contents` (a Cloud Code envelope wrapper) + * - Kiro-translated bodies (`openai-to-kiro.ts` `buildKiroPayload`): nested + * under `conversationState.currentMessage.userInputMessage.content` (the + * current turn) plus `conversationState.history` (prior turns) + * + * Falling back to only `messages` made every non-OpenAI-format body hash the + * prompt as `null`, colliding different prompts onto the same dedup hash + * (#10249). The Antigravity/Kiro nesting was still missed by the flat + * `messages ?? contents ?? input` fallback chain, so different prompts + * targeting those two providers still collided (#10438). + */ +function extractPromptContent(body: Record): unknown { + if (body.messages !== undefined) return body.messages; + if (body.contents !== undefined) return body.contents; + if (body.input !== undefined) return body.input; + + // Antigravity Cloud Code envelope: { request: { contents, ... } } + const request = asRecord(body.request); + if (request && request.contents !== undefined) { + return request.contents; + } + + // Kiro conversationState envelope: + // { conversationState: { currentMessage: { userInputMessage: { content } }, history } } + const conversationState = asRecord(body.conversationState); + if (conversationState) { + const currentMessage = asRecord(conversationState.currentMessage); + const userInputMessage = asRecord(currentMessage?.userInputMessage); + if (userInputMessage || conversationState.history !== undefined) { + return { + content: userInputMessage?.content ?? null, + history: conversationState.history ?? null, + }; + } + } + + return null; +} + +/** + * Extract the system/instruction content that shapes generation but is not + * carried in the message list itself. Two requests with the same user + * message but a different system prompt must hash differently — omitting + * this field let them collide. + * + * - Claude-translated bodies (`openai-to-claude.ts`): `system` + * - Responses-API-translated bodies (`openai-responses/toResponses.ts`): + * `instructions` + * - Gemini-translated bodies (`openai-to-gemini.ts`, `claude-to-gemini.ts`): + * `systemInstruction` + * - Antigravity-translated bodies: nested under `request.systemInstruction` + * (note: the client system prompt is folded into `request.contents[0]` + * instead per #9030, so this is usually the constant Antigravity + * default — it is still included for completeness/future-proofing) + */ +function extractSystemContent(body: Record): unknown { + if (body.system !== undefined) return body.system; + if (body.instructions !== undefined) return body.instructions; + if (body.systemInstruction !== undefined) return body.systemInstruction; + + const request = asRecord(body.request); + if (request && request.systemInstruction !== undefined) { + return request.systemInstruction; + } + + return null; +} + /** * Compute a deterministic hash for a request body. - * Includes: model, messages, temperature, tools, tool_choice, max_tokens, response_format + * Includes: model, messages/prompt content, system/instructions, temperature, + * tools, tool_choice, max_tokens, response_format * Excludes: stream, user, metadata (don't affect LLM output) + * + * `computeRequestHash` is called post-translation (`chatCore.ts`, on + * `translatedBody`), so the body shape here is whatever the target provider + * format produced — see `extractPromptContent`/`extractSystemContent` for the + * full list of shapes this must cover (#10249, #10438). */ export function computeRequestHash(requestBody: unknown): string { const body = requestBody as Record; const canonical = { model: body.model ?? null, - messages: body.messages ?? null, + messages: extractPromptContent(body), + system: extractSystemContent(body), temperature: typeof body.temperature === "number" ? body.temperature : 1.0, tools: body.tools ?? null, tool_choice: body.tool_choice ?? null, diff --git a/open-sse/services/speechCombo.ts b/open-sse/services/speechCombo.ts new file mode 100644 index 0000000000..1d73337784 --- /dev/null +++ b/open-sse/services/speechCombo.ts @@ -0,0 +1,182 @@ +/** + * Speech Combo Strategy Execution + * + * Mirrors imageCombo for /v1/audio/speech: expands combo targets via + * resolveComboTargets(), filters to speech-capable targets, runs each through + * handleAudioSpeech() in priority order, and returns the first success or the + * last failure. + * + * Unlike the image and video strategies, the speech handler returns a Response + * carrying an audio stream rather than a JSON result object, so success is read + * off `response.ok` and the upstream body is passed through untouched — only + * ADD-only meta headers are attached, matching the direct route. + */ +import { getComboByName, getCombos } from "@/lib/db/combos"; +import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; +import { parseSpeechModel, getSpeechProvider } from "@omniroute/open-sse/config/audioRegistry.ts"; +import { resolveDynamicAudioProviders } from "@/app/api/v1/_shared/audioProviderNodes"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts"; +import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta"; +import { generateRequestId } from "@/shared/utils/requestId"; +import { calculateModalCost } from "@/lib/usage/costCalculator"; +import { getClientIpFromRequest } from "@/lib/ipUtils"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; + +/** + * Execute a full combo strategy for a text-to-speech request. + */ +export async function executeSpeechCombo( + comboName: string, + body: Record, + auth: { + request: Request; + policy: { apiKeyInfo?: { id?: string; name?: string } | null }; + }, + startTime: number +): Promise { + const combo = await getComboByName(comboName); + if (!combo) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo not found: ${comboName}`); + } + + const allCombos = await getCombos(); + const targets = resolveComboTargets(combo as never, allCombos as never); + if (!targets || targets.length === 0) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo "${comboName}" has no usable targets`); + } + + // Dynamic provider nodes are resolved once and reused for every target, the + // same list the direct route builds. + const dynamicProviders = await resolveDynamicAudioProviders("/audio/speech", "audio-speech"); + + // Filter at model level, not provider level. parseSpeechModel resolves a + // provider prefix without checking that the model behind it can speak, so a + // chat model on a speech-capable provider (openai/gpt-4o) would otherwise be + // accepted as a target and only fail once dispatched. + const speechTargets = targets.filter((t) => { + if (!t.modelStr) return false; + const { provider, model } = parseSpeechModel(t.modelStr, dynamicProviders); + if (!provider) return false; + const config = + getSpeechProvider(provider) || dynamicProviders.find((dp) => dp.id === provider) || null; + if (!config) return false; + // Dynamic provider nodes do not always enumerate their models; when the + // list is absent there is nothing to check against, so the target stands. + if (!Array.isArray(config.models) || config.models.length === 0) return true; + return config.models.some((m: { id: string }) => m.id === model || m.id === t.modelStr); + }); + + if (speechTargets.length === 0) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No speech-capable targets in combo "${comboName}"` + ); + } + + const clientIp = getClientIpFromRequest(auth.request); + let lastError: { status: number; error: string } | null = null; + let fallbackCount = 0; + + for (const target of speechTargets) { + const { provider: targetProvider, model: resolvedModel } = parseSpeechModel( + target.modelStr, + dynamicProviders + ); + if (!targetProvider) { + lastError = { status: 400, error: `Invalid speech model: ${target.modelStr}` }; + fallbackCount += 1; + continue; + } + + const providerConfig = + getSpeechProvider(targetProvider) || + dynamicProviders.find((dp) => dp.id === targetProvider) || + null; + + let credentials = null; + if (providerConfig && providerConfig.authType !== "none") { + const credentialKey = providerConfig.credentialProviderId || targetProvider; + try { + credentials = await getProviderCredentialsWithQuotaPreflight(credentialKey); + } catch { + lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` }; + fallbackCount += 1; + continue; + } + + if (!credentials) { + lastError = { status: 400, error: `No credentials for provider: ${targetProvider}` }; + fallbackCount += 1; + continue; + } + + if (isAllRateLimitedCredentials(credentials)) { + lastError = { status: 429, error: `[${targetProvider}] All accounts rate limited` }; + fallbackCount += 1; + continue; + } + } + + const response = await handleAudioSpeech({ + body: { ...body, model: target.modelStr }, + credentials, + resolvedProvider: providerConfig, + resolvedModel, + clientIp, + }); + + if (response?.ok) { + await clearRecoveredProviderState(credentials); + const characters = typeof body.input === "string" ? body.input.length : 0; + const costUsd = await calculateModalCost( + "audio", + targetProvider, + resolvedModel || target.modelStr, + { characters } + ); + return attachOmniRouteMetaToResponse(response, { + provider: targetProvider, + model: resolvedModel || target.modelStr, + costUsd, + latencyMs: Date.now() - startTime, + requestId: generateRequestId(), + strategy: "priority", + fallbackAttempts: fallbackCount, + }); + } + + const status = response?.status || 500; + // The body is read only on the failure path, where it is small and about to + // be discarded anyway; a successful audio stream is never consumed here. + let error = `Speech generation failed (HTTP ${status})`; + try { + const text = await response?.clone().text(); + if (text) error = text.slice(0, 300); + } catch { + // non-text or already-consumed body — keep the status-line message + } + + if (status === 400 || status === 401 || status === 403) { + return errorResponse(status, `[${targetProvider}] ${error}`); + } + + lastError = { status, error: `[${targetProvider}] ${error}` }; + fallbackCount += 1; + } + + const errorPayload = toJsonErrorPayload( + lastError?.error || "All combo targets failed", + "Speech combo targets all failed" + ); + return new Response(JSON.stringify(errorPayload), { + status: lastError?.status || 502, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 3634f7e3b5..7f4a97486a 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -71,6 +71,7 @@ import { getFirecrawlUsage } from "./usage/firecrawl.ts"; import { getCommandCodeUsage } from "./usage/command-code.ts"; import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts"; import { getConolUsage } from "./conolUsage.ts"; +import { getAgentrouterUsage } from "./usage/agentrouter.ts"; type JsonRecord = Record; type UsageProviderConnection = JsonRecord & { @@ -138,6 +139,8 @@ export const USAGE_FETCHER_PROVIDERS = [ "command-code", "conol-web", "cnl", + // AgentRouter (New-API) console balance (GET /api/user/self) + "agentrouter", ] as const; export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; @@ -244,6 +247,8 @@ export async function getUsageForProvider( case "conol-web": case "cnl": return await getConolUsage(apiKey || accessToken, providerSpecificData); + case "agentrouter": + return await getAgentrouterUsage(id, connection); default: return { message: `Usage API not implemented for ${provider}` }; } diff --git a/open-sse/services/usage/agentrouter.ts b/open-sse/services/usage/agentrouter.ts new file mode 100644 index 0000000000..56b0509c63 --- /dev/null +++ b/open-sse/services/usage/agentrouter.ts @@ -0,0 +1,73 @@ +/** + * usage/agentrouter.ts — AgentRouter (New-API) balance quota shapes the Provider + * Limits dashboard expects. + * + * Reuses the already-registered preflight/monitor fetcher (OpenAI-style routing + * apiKey vs console System Access Token + New-Api-User id) instead of re-implementing + * the HTTP call, so the 60s in-memory cache in agentrouterQuotaFetcher.ts is shared. + * + * AgentRouter exposes a raw New-API credit balance, not a real grant to divide by — + * so, following the DeepSeek boolean-availability precedent, `remainingPercentage` is + * only a two-state signal (100 = has balance, 0 = exhausted) used for the quota-card + * bar color. The human-meaningful number — the actual USD balance (rawQuota / + * QUOTA_PER_UNIT) — MUST travel inside `quotas.balance.remaining` so the Dashboard + * Quota UI's credits-row renderer (quotaParsing.ts::parseAgentrouterQuota, which reads + * `quota.remaining`/`quota.currency`) can format it with a currency symbol instead of + * dropping it: `getUsageForProvider()`'s top-level `remainingUsd`/`availableUsd`/ + * `balance` sibling fields exist for API/CLI consumers only — parseQuotaData() (the + * Dashboard renderer) never reads them, only `data.quotas` (#10078 follow-up). + */ +import { fetchAgentrouterQuota, type AgentrouterQuota } from "../agentrouterQuotaFetcher.ts"; +import { type UsageQuota } from "./quota.ts"; + +type JsonRecord = Record; + +/** + * AgentRouter balance → dashboard usage shape. + * + * Returns `{ message }` when the fetch returns null (no console credentials, an + * upstream error, or a rejected token), which the Provider Limits UI renders as a + * graceful per-row status instead of crashing the whole page. Otherwise shapes the + * balance into a single USD `quotas.balance` entry whose `remaining` field carries + * the exact dollar amount (never negative, exactly 0 when the wallet is exhausted). + */ +export async function getAgentrouterUsage( + connectionId: string | undefined, + connection: JsonRecord +) { + const quota = (await fetchAgentrouterQuota( + connectionId || "", + connection + )) as AgentrouterQuota | null; + + if (!quota) { + return { + message: + "AgentRouter balance not available. Add the Console API Key + New-API User ID to the connection to view usage.", + }; + } + + // `dollarBalance` is already `rawQuota / QUOTA_PER_UNIT` (agentrouterQuotaFetcher.ts); + // clamp defensively so an exhausted/mis-parsed wallet never surfaces as negative. + const remainingUsd = Math.max(0, quota.dollarBalance); + const remainingPercentage = quota.limitReached ? 0 : 100; + + const balance: UsageQuota = { + used: 0, + total: 0, + remaining: remainingUsd, + remainingPercentage, + resetAt: quota.resetAt ?? null, + unlimited: true, + currency: "USD", + displayName: "Wallet Balance (USD)", + }; + + return { + plan: "AgentRouter", + quotas: { balance }, + remainingUsd, + availableUsd: remainingUsd, + balance: remainingUsd, + }; +} \ No newline at end of file diff --git a/open-sse/services/videoCombo.ts b/open-sse/services/videoCombo.ts new file mode 100644 index 0000000000..9ab9f84c67 --- /dev/null +++ b/open-sse/services/videoCombo.ts @@ -0,0 +1,215 @@ +/** + * Video Combo Strategy Execution + * + * Mirrors imageCombo for /v1/videos/generations: expands combo targets via + * resolveComboTargets(), filters to video-capable targets (built-in registry + * models plus custom OpenAI-compatible provider nodes tagged with the + * "videos" endpoint — same coverage as the direct route), runs each through + * handleVideoGeneration() in priority order, and returns the first success or + * the last failure. + * + * Terminal-vs-retryable classification matches the image strategy: 400/401/403 + * stop the walk (a bad model or a banned key will not get better on the next + * target), everything else advances. A missing prompt against a + * prompt-required target is an exception to that rule: it is per-target (some + * combo targets may be prompt-optional I2V models), so it is treated as a + * retryable skip rather than a terminal failure. + */ +import { getComboByName, getCombos } from "@/lib/db/combos"; +import { resolveComboTargets } from "@omniroute/open-sse/services/combo.ts"; +import { getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts"; +import { resolveVideoCredentialProvider } from "@omniroute/open-sse/handlers/videoGeneration/googleFlow.ts"; +import { + getProviderCredentialsWithQuotaPreflight, + clearRecoveredProviderState, +} from "@/sse/services/auth"; +import { isAllRateLimitedCredentials } from "@/app/api/v1/_shared/rateLimit"; +import { handleVideoGeneration } from "@omniroute/open-sse/handlers/videoGeneration.ts"; +import { + isMediaGenerationFailure, + promptRequiredResponse, + successfulMediaGenerationResponse, +} from "@/app/api/v1/_shared/mediaGenerationRoute"; +import type { MediaGenerationResultLike } from "@/app/api/v1/_shared/mediaGenerationRoute"; +import { + isVideoPromptOptional, + resolveLocalOverrideCredentials, + resolveVideoModelTarget, +} from "@/app/api/v1/_shared/videoModelResolution"; +import type { VideoModelTarget } from "@/app/api/v1/_shared/videoModelResolution"; +import { toJsonErrorPayload } from "@/shared/utils/upstreamError"; +import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; +import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; +import * as logger from "@/sse/utils/logger"; + +/** + * Execute a full combo strategy for a video generation request. + */ +export async function executeVideoCombo( + comboName: string, + body: Record, + auth: { + request: Request; + policy: { apiKeyInfo?: { id?: string; name?: string } | null }; + }, + startTime: number, + log: typeof logger +): Promise { + const combo = await getComboByName(comboName); + if (!combo) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo not found: ${comboName}`); + } + + const allCombos = await getCombos(); + const targets = resolveComboTargets(combo as never, allCombos as never); + if (!targets || targets.length === 0) { + return errorResponse(HTTP_STATUS.BAD_REQUEST, `Combo "${comboName}" has no usable targets`); + } + + // Resolve every target once — built-in registry first, then custom + // OpenAI-compatible provider nodes tagged with the "videos" endpoint — + // and filter to video-capable ones. Resolving up front (rather than in the + // execution loop below) lets prompt validation run against the real + // expanded target set instead of the unresolved combo name. + const videoTargets: Array<{ modelStr: string; resolved: VideoModelTarget }> = []; + for (const t of targets) { + if (!t.modelStr) continue; + const resolved = await resolveVideoModelTarget(t.modelStr); + if (resolved.provider) { + videoTargets.push({ modelStr: t.modelStr, resolved }); + } + } + + if (videoTargets.length === 0) { + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + `No video-capable targets in combo "${comboName}"` + ); + } + + let lastError: { status: number; error: string } | null = null; + let fallbackCount = 0; + + for (const { modelStr, resolved } of videoTargets) { + const { provider: targetProvider, model: targetModel, isCustomModel } = resolved; + if (!targetProvider) { + lastError = { status: 400, error: `Invalid video model: ${modelStr}` }; + fallbackCount += 1; + continue; + } + + // Prompt requirements are per-target: some combo targets (I2V models) are + // prompt-optional and others are not, so a missing prompt only rules out + // this target rather than the whole combo. + if (!isVideoPromptOptional(resolved)) { + const promptError = promptRequiredResponse(body); + if (promptError) { + lastError = { status: 400, error: `[${targetProvider}] Prompt is required` }; + fallbackCount += 1; + continue; + } + } + + // Local providers (authType "none") carry no credential by default, but a + // configured per-connection override (e.g. a ComfyUI base URL) must still + // be honored, exactly as the direct route treats them. + const providerConfig = getVideoProvider(targetProvider); + let credentials = null; + if (providerConfig && providerConfig.authType !== "none") { + try { + credentials = await getProviderCredentialsWithQuotaPreflight( + resolveVideoCredentialProvider(targetProvider) + ); + } catch { + lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` }; + fallbackCount += 1; + continue; + } + + if (!credentials) { + lastError = { status: 400, error: `No credentials for video provider: ${targetProvider}` }; + fallbackCount += 1; + continue; + } + + if (isAllRateLimitedCredentials(credentials)) { + lastError = { status: 429, error: `[${targetProvider}] All accounts rate limited` }; + fallbackCount += 1; + continue; + } + } else if (isCustomModel) { + try { + credentials = await getProviderCredentialsWithQuotaPreflight( + targetProvider, + null, + null, + targetModel + ); + } catch { + lastError = { status: 502, error: `Failed to resolve credentials for ${targetProvider}` }; + fallbackCount += 1; + continue; + } + + if (!credentials) { + lastError = { + status: 400, + error: `No credentials for custom video provider: ${targetProvider}`, + }; + fallbackCount += 1; + continue; + } + + if (isAllRateLimitedCredentials(credentials)) { + lastError = { status: 429, error: `[${targetProvider}] All accounts rate limited` }; + fallbackCount += 1; + continue; + } + } else if (providerConfig?.authType === "none") { + credentials = await resolveLocalOverrideCredentials(targetProvider); + } + + const result: MediaGenerationResultLike = await handleVideoGeneration({ + body: { ...body, model: modelStr }, + credentials, + log, + ...(isCustomModel && { resolvedProvider: targetProvider }), + }); + + if (!isMediaGenerationFailure(result)) { + await clearRecoveredProviderState(credentials); + return successfulMediaGenerationResponse({ + result: { data: result.data }, + billingMode: "video", + provider: targetProvider, + model: modelStr, + startTime, + duration: body.duration, + strategy: "priority", + fallbackAttempts: fallbackCount, + }); + } + + const status = (result as { status?: number }).status || 500; + const error = + typeof (result as { error?: unknown }).error === "string" + ? (result as { error: string }).error + : "Video generation failed"; + + if (status === 400 || status === 401 || status === 403) { + return errorResponse(status, `[${targetProvider}] ${error}`); + } + + lastError = { status, error: `[${targetProvider}] ${error}` }; + fallbackCount += 1; + } + + const errorPayload = toJsonErrorPayload( + lastError?.error || "All combo targets failed", + "Video combo targets all failed" + ); + return new Response(JSON.stringify(errorPayload), { + status: lastError?.status || 502, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/open-sse/services/xaiMessageCap.ts b/open-sse/services/xaiMessageCap.ts new file mode 100644 index 0000000000..92886e68cd --- /dev/null +++ b/open-sse/services/xaiMessageCap.ts @@ -0,0 +1,129 @@ +/** + * xAI rejects a request with HTTP 413 when chat history exceeds 800 items: + * "Chat history exceeds the 800-message limit; compact the conversation and retry." + * + * Token-based compression does not catch this: a long agent loop of tiny + * tool calls still fits a 256k–500k window. Cap the arrays xAI actually + * counts — Chat Completions `messages` and Responses `input` — at the + * executor edge, after any chat→Responses expansion. + */ +import { + fixToolAdjacency, + fixToolPairs, + stripTrailingAssistantOrphanToolUse, +} from "./contextManager.ts"; + +export const XAI_CHAT_HISTORY_LIMIT = 800; + +type HistoryItem = Record; + +function isSystemRole(item: HistoryItem): boolean { + return item.role === "system" || item.role === "developer"; +} + +function repairChatMessages(messages: HistoryItem[]): HistoryItem[] { + let result = fixToolPairs(messages); + result = fixToolAdjacency(result); + result = fixToolPairs(result); + return stripTrailingAssistantOrphanToolUse(result); +} + +/** + * Keep system/developer messages plus the newest tail, then drop tool-call + * orphans created by the cut. If the repaired list is still over the limit + * (lots of system messages), take the newest `limit` items and repair again. + */ +export function capXaiChatMessages( + messages: HistoryItem[], + limit = XAI_CHAT_HISTORY_LIMIT +): HistoryItem[] { + if (!Array.isArray(messages) || messages.length <= limit) return messages; + + const system = messages.filter(isSystemRole); + const nonSystem = messages.filter((item) => !isSystemRole(item)); + const budget = Math.max(2, limit - system.length); + let result = repairChatMessages([...system, ...nonSystem.slice(-budget)]); + + if (result.length > limit) { + result = repairChatMessages(result.slice(-limit)); + } + return result; +} + +function lastUserIndex(items: HistoryItem[]): number { + for (let i = items.length - 1; i >= 0; i--) { + if (items[i].role === "user") return i; + } + return -1; +} + +/** + * Responses `input` expands one assistant+tools chat turn into many items + * (`function_call` + `function_call_output`). Drop orphans left by a tail cut: + * outputs whose call was dropped, and mid-history calls whose output was + * dropped. Trailing unmatched `function_call`s (the in-flight turn) stay. + */ +export function repairXaiResponsesInput(items: HistoryItem[]): HistoryItem[] { + const callIds = new Set(); + const outputIds = new Set(); + for (const item of items) { + if (typeof item.call_id !== "string") continue; + if (item.type === "function_call") callIds.add(item.call_id); + if (item.type === "function_call_output") outputIds.add(item.call_id); + } + + const lastUser = lastUserIndex(items); + return items.filter((item, idx) => { + if (item.type === "function_call_output") { + return typeof item.call_id === "string" && callIds.has(item.call_id); + } + if (item.type === "function_call") { + if (typeof item.call_id === "string" && outputIds.has(item.call_id)) return true; + return lastUser < 0 || idx > lastUser; + } + return true; + }); +} + +export function capXaiResponsesInput( + input: HistoryItem[], + limit = XAI_CHAT_HISTORY_LIMIT +): HistoryItem[] { + if (!Array.isArray(input) || input.length <= limit) return input; + + let result = repairXaiResponsesInput(input.slice(-limit)); + if (result.length > limit) { + result = repairXaiResponsesInput(result.slice(-limit)); + } + return result; +} + +/** + * Cap whichever history array the body is using. No-op (same object / + * same array refs) when already within the limit. + */ +export function capXaiRequestHistory( + body: Record +): Record { + if (!body || typeof body !== "object") return body; + + const next: Record = { ...body }; + let changed = false; + + if (Array.isArray(body.messages)) { + const messages = capXaiChatMessages(body.messages as HistoryItem[]); + if (messages !== body.messages) { + next.messages = messages; + changed = true; + } + } + if (Array.isArray(body.input)) { + const input = capXaiResponsesInput(body.input as HistoryItem[]); + if (input !== body.input) { + next.input = input; + changed = true; + } + } + + return changed ? next : body; +} diff --git a/open-sse/translator/helpers/claudeHelper.ts b/open-sse/translator/helpers/claudeHelper.ts index 05dad32149..a3878b7256 100644 --- a/open-sse/translator/helpers/claudeHelper.ts +++ b/open-sse/translator/helpers/claudeHelper.ts @@ -3,6 +3,7 @@ import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingS import { lookupReasoning, recordReplay } from "../../services/reasoningCache.ts"; import { getModelTargetFormat } from "../../config/providerModels.ts"; import { NON_ANTHROPIC_THINKING_PLACEHOLDER } from "../../utils/reasoningPlaceholder.ts"; +import { sanitizeToolId } from "./schemaCoercion.ts"; export { NON_ANTHROPIC_THINKING_PLACEHOLDER } from "../../utils/reasoningPlaceholder.ts"; @@ -429,6 +430,22 @@ export function prepareClaudeRequest( msg.content = msg.content.filter( (block) => block.type !== "tool_result" || block.tool_use_id ); + // Anthropic-shape upstreams enforce `^[a-zA-Z0-9_-]+$` on tool ids. Client + // histories can carry ids with `.`/`:`/`#` (e.g. replayed from another + // provider), which 400s as TOOL_SCHEMA_INVALID. Rewrite both sides with the + // same function so tool_use/tool_result pairing survives — the later + // ordering passes match on these ids. + for (const block of msg.content) { + if (block.type === "tool_use" && typeof block.id === "string" && block.id) { + block.id = sanitizeToolId(block.id); + } else if ( + block.type === "tool_result" && + typeof block.tool_use_id === "string" && + block.tool_use_id + ) { + block.tool_use_id = sanitizeToolId(block.tool_use_id); + } + } } } diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 623e39cba3..8a67b07bc5 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -727,5 +727,33 @@ export function cleanJSONSchemaForAntigravity(schema: unknown): unknown { injectObjectType(cleaned); + // Phase 8: Ensure array types have an items schema (#10578). + // Gemini strictly requires array parameters to define their `items` schema. + // If an MCP tool defines an array but forgets the items, inject a safe default. + function ensureArrayItems(obj: unknown): void { + if (!obj || typeof obj !== "object") return; + + if (Array.isArray(obj)) { + for (const item of obj) { + ensureArrayItems(item); + } + return; + } + + const record = obj as JsonRecord; + if (record.type === "array" && !record.items) { + record.items = { type: "string" }; + } + + // Recurse into remaining values. + for (const value of Object.values(record)) { + if (value && typeof value === "object") { + ensureArrayItems(value); + } + } + } + + ensureArrayItems(cleaned); + return cleaned; } diff --git a/open-sse/translator/request/openai-to-claude.ts b/open-sse/translator/request/openai-to-claude.ts index 7fb844bda9..ff07029e61 100644 --- a/open-sse/translator/request/openai-to-claude.ts +++ b/open-sse/translator/request/openai-to-claude.ts @@ -7,7 +7,7 @@ import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; import { safeParseJSON } from "../helpers/jsonUtil.ts"; import { applyKimiCodingThinking } from "../helpers/claudeHelper.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; -import { isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; +import { getDefaultThinkingBudget, isAdaptiveThinkingOnly } from "../../../src/shared/constants/modelSpecs.ts"; import { fitThinkingToMaxTokens } from "./openai-to-claude/thinkingBudget.ts"; import { enforceToolResultAdjacency } from "./openai-to-claude/toolResultAdjacency.ts"; import { sanitizeToolResultId } from "./openai-to-claude/sanitizeToolResultId.ts"; @@ -16,6 +16,12 @@ import { sanitizeToolResultId } from "./openai-to-claude/sanitizeToolResultId.ts // adaptive-only Claude models (Opus 4.7+/Fable 5) without ever emitting a manual budget. const ADAPTIVE_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]); +// Safe manual budget when `thinking:{type:"adaptive"}` must be downgraded to the +// compatible manual `type:"enabled"` form for a model that does not support adaptive +// thinking (#10119). 1024 is both Anthropic's MIN thinking budget (thinkingBudget.ts) +// and the `low` effort bucket — conservative for small-context models like Haiku. +const ADAPTIVE_DOWNGRADE_BUDGET = 1024; + // Prefix for Claude OAuth tool names to avoid conflicts // Can be disabled per-request via body._disableToolPrefix = true export const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_"; @@ -179,11 +185,27 @@ export function openaiToClaudeRequest(model, body, stream, credentials = null) { if (isKimiCoding) { applyKimiCodingThinking(result, body); } else if (body.thinking) { - result.thinking = { - type: body.thinking.type || "enabled", - ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), - ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), - }; + const thinkingType = body.thinking.type || "enabled"; + if (thinkingType === "adaptive" && !isAdaptiveThinkingOnly(model)) { + // Downgrade guard (#10119): a request can carry `thinking:{type:"adaptive"}` — the + // shape built for an adaptive-only sibling (Opus 4.7+/Sonnet-5) in a combo — and be + // re-routed by combo/fallback to a model that only accepts manual extended thinking + // (e.g. claude-haiku-4-5-20251001). Anthropic rejects `adaptive` on those models with + // "adaptive thinking is not supported on this model". Convert to the compatible manual + // `enabled` form with a safe budget instead of forwarding an incompatible type. + const callerBudget = Number(body.thinking.budget_tokens); + const safeBudget = + (Number.isFinite(callerBudget) && callerBudget > 0 ? callerBudget : 0) || + getDefaultThinkingBudget(model) || + ADAPTIVE_DOWNGRADE_BUDGET; + result.thinking = { type: "enabled", budget_tokens: safeBudget }; + } else { + result.thinking = { + type: thinkingType, + ...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }), + ...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens }), + }; + } } else if (body.reasoning_effort) { // Convert OpenAI reasoning_effort to Claude thinking format (#627) // Clients like OpenCode send reasoning_effort via @ai-sdk/openai-compatible diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 18fb6ca6fb..9b52797e96 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -7,6 +7,94 @@ import { storeGeminiThoughtSignature, } from "../../services/geminiThoughtSignatureStore.ts"; +function normalizeToolName(name: string, toolNameMap?: Map | null): string { + return restoreClaudeToolName(name, toolNameMap); +} + +function extractXmlInvokeBlocks( + text: string, + state: { _xmlInvokeBuffer?: string } +): { cleaned: string; toolCalls: Array<{ id: string; name: string; args: Record }> } { + const toolCalls: Array<{ id: string; name: string; args: Record }> = []; + const combined = (state._xmlInvokeBuffer || "") + text; + state._xmlInvokeBuffer = ""; + let remaining = combined; + let cleaned = ""; + + while (remaining.length > 0) { + const invokeMatch = remaining.match(//); + const toolCallTagMatch = remaining.match(//); + const toolCallTextMatch = remaining.match(/TOOL_CALL\s+([A-Za-z0-9_]+):\s*/); + + const matches = [ + invokeMatch ? { type: "invoke" as const, index: invokeMatch.index!, data: invokeMatch } : null, + toolCallTagMatch ? { type: "tool_call_tag" as const, index: toolCallTagMatch.index!, data: toolCallTagMatch } : null, + toolCallTextMatch ? { type: "tool_call_text" as const, index: toolCallTextMatch.index!, data: toolCallTextMatch } : null, + ].filter(Boolean).sort((a, b) => a!.index - b!.index); + + if (matches.length === 0) { + cleaned += remaining; + break; + } + + const first = matches[0]!; + cleaned += remaining.slice(0, first.index); + const rest = remaining.slice(first.index); + + if (first.type === "invoke") { + const startMatch = first.data; + const endMatch = rest.match(/<\/invoke>/); + if (!endMatch) { state._xmlInvokeBuffer = rest; break; } + const innerXml = rest.slice(startMatch[0].length, endMatch.index!); + const fullLength = endMatch.index! + endMatch[0].length; + const args: Record = {}; + const paramRegex = /]*>([\s\S]*?)<\/parameter>/g; + let pm; + while ((pm = paramRegex.exec(innerXml)) !== null) { args[pm[1]] = pm[2].trim(); } + toolCalls.push({ id: `toolu_xml_${Date.now()}_${toolCalls.length}`, name: startMatch[1], args }); + remaining = rest.slice(fullLength); + } else if (first.type === "tool_call_tag") { + const endMatch = rest.match(/<\/tool_call>/); + if (!endMatch) { state._xmlInvokeBuffer = rest; break; } + const innerJson = rest.slice("".length, endMatch.index!).trim(); + const fullLength = endMatch.index! + "".length; + try { + const parsed = JSON.parse(innerJson) as Record; + const name = (parsed.name || parsed.tool_name || "") as string; + const rawArgs = parsed.arguments || parsed.args || parsed.parameters || {}; + const args: Record = typeof rawArgs === "string" ? JSON.parse(rawArgs) : (rawArgs as Record); + if (name) { toolCalls.push({ id: `toolu_txt_${Date.now()}_${toolCalls.length}`, name, args }); } + } catch { cleaned += rest.slice(0, fullLength); } + remaining = rest.slice(fullLength); + } else { + const startMatch = first.data; + const toolName = startMatch[1]; + const afterPrefix = rest.slice(startMatch[0].length); + let depth = 0, inString = false, escape = false, jsonEndIndex = -1; + for (let i = 0; i < afterPrefix.length; i++) { + const c = afterPrefix[i]; + if (escape) { escape = false; continue; } + if (c === "\\" && inString) { escape = true; continue; } + if (c === '"') { inString = !inString; continue; } + if (!inString) { + if (c === "{") depth++; + else if (c === "}") { depth--; if (depth === 0) { jsonEndIndex = i + 1; break; } } + } + } + if (jsonEndIndex === -1) { state._xmlInvokeBuffer = rest; break; } + const jsonStr = afterPrefix.slice(0, jsonEndIndex); + const fullLength = startMatch[0].length + jsonEndIndex; + try { + const args = JSON.parse(jsonStr) as Record; + toolCalls.push({ id: `toolu_txt_${Date.now()}_${toolCalls.length}`, name: toolName, args }); + } catch { cleaned += rest.slice(0, fullLength); } + remaining = rest.slice(fullLength); + } + } + + return { cleaned, toolCalls }; +} + /** * Direct Gemini → Claude response translator. * Converts Gemini streaming chunks directly to Claude Messages API @@ -104,10 +192,7 @@ export function geminiToClaudeResponse(chunk, state) { } const fc = part.functionCall; const rawToolName = fc.name; - // #9008: honor the request's original casing via toolNameMap before any - // REVERSE_MAP lowercase fallback (#7926). Blind REVERSE_MAP broke Claude - // Code (Read/WebSearch → read/websearch → "No such tool available"). - const restoredToolName = restoreClaudeToolName( + const restoredToolName = normalizeToolName( typeof rawToolName === "string" ? rawToolName : "", state.toolNameMap instanceof Map ? state.toolNameMap : null ); @@ -161,22 +246,66 @@ export function geminiToClaudeResponse(chunk, state) { !part.functionCall; if (isRegularText || isTextAfterThinking) { - // Open a new text block only if none is open yet - if (state.openTextBlockIdx === null) { - const idx = state.contentBlockIndex++; - state.openTextBlockIdx = idx; + const { cleaned, toolCalls: textToolCalls } = extractXmlInvokeBlocks(part.text, state); + + // Process any extracted text-format tool calls (, TOOL_CALL, ) + if (textToolCalls.length > 0) { + if (state.openTextBlockIdx !== null) { + results.push({ type: "content_block_stop", index: state.openTextBlockIdx }); + state.openTextBlockIdx = null; + } + for (const tc of textToolCalls) { + const idx = state.contentBlockIndex++; + const restoredToolName = restoreClaudeToolName( + tc.name, + state.toolNameMap instanceof Map ? state.toolNameMap : null + ); + const signatureForToolCall = + (typeof hasThoughtSig === "string" && hasThoughtSig.length > 0 ? hasThoughtSig : null) || + (typeof state.pendingThoughtSignature === "string" && + state.pendingThoughtSignature.length > 0 + ? state.pendingThoughtSignature + : null); + if (signatureForToolCall) { + storeGeminiThoughtSignature( + buildGeminiThoughtSignatureKey(state.signatureNamespace, tc.id), + signatureForToolCall + ); + state.pendingThoughtSignature = null; + } + + results.push({ + type: "content_block_start", + index: idx, + content_block: { type: "tool_use", id: tc.id, name: restoredToolName, input: {} }, + }); + results.push({ + type: "content_block_delta", + index: idx, + delta: { type: "input_json_delta", partial_json: JSON.stringify(tc.args || {}) }, + }); + results.push({ type: "content_block_stop", index: idx }); + if (!state.hasToolUse) state.hasToolUse = true; + } + } + + if (cleaned) { + // Open a new text block only if none is open yet + if (state.openTextBlockIdx === null) { + const idx = state.contentBlockIndex++; + state.openTextBlockIdx = idx; + results.push({ + type: "content_block_start", + index: idx, + content_block: { type: "text", text: "" }, + }); + } results.push({ - type: "content_block_start", - index: idx, - content_block: { type: "text", text: "" }, + type: "content_block_delta", + index: state.openTextBlockIdx, + delta: { type: "text_delta", text: cleaned }, }); } - // Always emit delta into the SAME open block (no open+close per chunk) - results.push({ - type: "content_block_delta", - index: state.openTextBlockIdx, - delta: { type: "text_delta", text: part.text }, - }); } } } @@ -217,17 +346,8 @@ export function geminiToClaudeResponse(chunk, state) { } else if (reason === "max_tokens" || reason === "length") { stopReason = "max_tokens"; } else if (reason === "safety" || reason === "recitation" || reason === "blocklist") { - // Content blocked by Gemini safety. Any text streamed before this finish - // reason has already been emitted to the client — this is unavoidable in - // SSE streaming. Map to end_turn (Claude has no "content blocked" reason). stopReason = "end_turn"; } else if (isAbortFinishReason(reason)) { - // Aborted/malformed tool call (e.g. MALFORMED_FUNCTION_CALL, - // UNEXPECTED_TOOL_CALL). Surface as tool_use rather than a clean end_turn - // so the client sees the turn did not complete normally. Same fix as the - // hub path (openai-to-claude.ts) — this direct Gemini→Claude translator is - // the one Claude Code hits through an antigravity/Gemini-routed model. - // Port of decolua/9router#2462 by @anhdiepmmk. stopReason = "tool_use"; } else { stopReason = "end_turn"; @@ -238,13 +358,11 @@ export function geminiToClaudeResponse(chunk, state) { delta: { stop_reason: stopReason, stop_sequence: null }, usage: state.usage || { input_tokens: 0, output_tokens: 0 }, }); - results.push({ type: "message_stop" }); } return results.length > 0 ? results : null; } -// Register as direct path: Gemini → Claude register(FORMATS.GEMINI, FORMATS.CLAUDE, null, geminiToClaudeResponse); register(FORMATS.ANTIGRAVITY, FORMATS.CLAUDE, null, geminiToClaudeResponse); diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 109a04f845..09ff1c8d7c 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -540,8 +540,9 @@ function emitToolCall(state, emit, tc) { // precedence ("...while preserving explicit function-tool precedence") but its // unconditional `toolName === "apply_patch"` OR never actually implemented the carve-out. const toolName = state.funcNames[tcIdx] || funcName || ""; + const lowerName = toolName.toLowerCase(); const isCustomTool = - (toolName === "apply_patch" && !state.toolSchemas?.has?.(toolName)) || + ((lowerName === "apply_patch" || lowerName === "applypatch") && !state.toolSchemas?.has?.(toolName)) || state.customToolNames?.has?.(toolName) === true; if (!state.funcCallIds[tcIdx] && newCallId) state.funcCallIds[tcIdx] = newCallId; @@ -611,8 +612,9 @@ function closeToolCall(state, emit, idx, recordAsCompleted = true) { const toolName = state.funcNames[idx] || ""; // See emitToolCall()'s isCustomTool comment — must stay in sync (both compute the // same classification independently for their respective add/close call sites). + const lowerName = toolName.toLowerCase(); const isCustomTool = - (toolName === "apply_patch" && !state.toolSchemas?.has?.(toolName)) || + ((lowerName === "apply_patch" || lowerName === "applypatch") && !state.toolSchemas?.has?.(toolName)) || state.customToolNames?.has?.(toolName) === true; let funcItem; diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index b8e4f103c0..a5540d51e8 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -1,7 +1,6 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts"; -import { caseInsensitiveToolNameLookup } from "../helpers/toolCallHelper.ts"; import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { isAbortFinishReason } from "../../utils/finishReason.ts"; @@ -9,7 +8,12 @@ import { isInternalReasoningPlaceholder, stripInternalReasoningPlaceholder, } from "../../utils/reasoningPlaceholder.ts"; -import { restoreClaudeToolName } from "../../services/claudeCodeToolRemapper.ts"; +import { REVERSE_MAP, restoreClaudeToolName } from "../../services/claudeCodeToolRemapper.ts"; +import { sanitizeToolId } from "../helpers/schemaCoercion.ts"; + +function normalizeToolName(name: string): string { + return REVERSE_MAP[name] ?? name; +} interface XmlToolCall { id: string; @@ -30,54 +34,108 @@ function extractXmlInvokeBlocks( state ): { cleaned: string; toolCalls: XmlToolCall[] } { const toolCalls: XmlToolCall[] = []; - - // Prepend any incomplete content from previous chunk const combined = (state._xmlInvokeBuffer || "") + text; state._xmlInvokeBuffer = ""; - let remaining = combined; let cleaned = ""; - while (true) { - const startMatch = remaining.match(//); - if (!startMatch) { + while (remaining.length > 0) { + // Find all possible tool call patterns and pick the earliest + const invokeMatch = remaining.match(//); + const toolCallTagMatch = remaining.match(//); + const toolCallTextMatch = remaining.match(/TOOL_CALL\s+([A-Za-z0-9_]+):\s*/); + + const matches = [ + invokeMatch ? { type: "invoke" as const, index: invokeMatch.index!, data: invokeMatch } : null, + toolCallTagMatch ? { type: "tool_call_tag" as const, index: toolCallTagMatch.index!, data: toolCallTagMatch } : null, + toolCallTextMatch ? { type: "tool_call_text" as const, index: toolCallTextMatch.index!, data: toolCallTextMatch } : null, + ].filter(Boolean).sort((a, b) => a!.index - b!.index); + + if (matches.length === 0) { cleaned += remaining; break; } - // Text before the block - cleaned += remaining.slice(0, startMatch.index); + const first = matches[0]!; + cleaned += remaining.slice(0, first.index); + const rest = remaining.slice(first.index); - const blockStart = startMatch.index; - const restAfterStart = remaining.slice(blockStart); - const endMatch = restAfterStart.match(/<\/invoke>/); - - if (!endMatch) { - // Incomplete block — buffer for next chunk - state._xmlInvokeBuffer = restAfterStart; - break; + if (first.type === "invoke") { + const startMatch = first.data; + const endMatch = rest.match(/<\/invoke>/); + if (!endMatch) { + state._xmlInvokeBuffer = rest; + break; + } + const innerXml = rest.slice(startMatch[0].length, endMatch.index!); + const fullLength = endMatch.index! + endMatch[0].length; + const args: Record = {}; + const paramRegex = /]*>([\s\S]*?)<\/parameter>/g; + let pm; + while ((pm = paramRegex.exec(innerXml)) !== null) { + args[pm[1]] = pm[2].trim(); + } + toolCalls.push({ + id: `toolu_xml_${Date.now()}_${toolCalls.length}`, + name: startMatch[1], + args, + }); + remaining = rest.slice(fullLength); + } else if (first.type === "tool_call_tag") { + const endMatch = rest.match(/<\/tool_call>/); + if (!endMatch) { + state._xmlInvokeBuffer = rest; + break; + } + const innerJson = rest.slice("".length, endMatch.index!).trim(); + const fullLength = endMatch.index! + "".length; + try { + const parsed = JSON.parse(innerJson) as Record; + const name = (parsed.name || parsed.tool_name || "") as string; + const rawArgs = parsed.arguments || parsed.args || parsed.parameters || {}; + const args: Record = + typeof rawArgs === "string" + ? JSON.parse(rawArgs) + : (rawArgs as Record); + if (name) { + toolCalls.push({ id: `toolu_txt_${Date.now()}_${toolCalls.length}`, name, args }); + } + } catch { + cleaned += rest.slice(0, fullLength); + } + remaining = rest.slice(fullLength); + } else { + const startMatch = first.data; + const toolName = startMatch[1]; + const afterPrefix = rest.slice(startMatch[0].length); + let depth = 0; + let inString = false; + let escape = false; + let jsonEndIndex = -1; + for (let i = 0; i < afterPrefix.length; i++) { + const c = afterPrefix[i]; + if (escape) { escape = false; continue; } + if (c === "\\" && inString) { escape = true; continue; } + if (c === '"') { inString = !inString; continue; } + if (!inString) { + if (c === "{") depth++; + else if (c === "}") { depth--; if (depth === 0) { jsonEndIndex = i + 1; break; } } + } + } + if (jsonEndIndex === -1) { + state._xmlInvokeBuffer = rest; + break; + } + const jsonStr = afterPrefix.slice(0, jsonEndIndex); + const fullLength = startMatch[0].length + jsonEndIndex; + try { + const args = JSON.parse(jsonStr) as Record; + toolCalls.push({ id: `toolu_txt_${Date.now()}_${toolCalls.length}`, name: toolName, args }); + } catch { + cleaned += rest.slice(0, fullLength); + } + remaining = rest.slice(fullLength); } - - // Complete block found - const innerXml = restAfterStart.slice(startMatch[0].length, endMatch.index); - const fullBlock = restAfterStart.slice(0, endMatch.index + endMatch[0].length); - - // Parse value - const args: Record = {}; - const paramRegex = /]*>([\s\S]*?)<\/parameter>/g; - let pm; - while ((pm = paramRegex.exec(innerXml)) !== null) { - args[pm[1]] = pm[2].trim(); - } - - toolCalls.push({ - id: `toolu_xml_${Date.now()}_${toolCalls.length}`, - name: startMatch[1], - args, - }); - - // Continue scanning after the block - remaining = remaining.slice(blockStart + fullBlock.length); } return { cleaned, toolCalls }; @@ -281,9 +339,8 @@ export function openaiToClaudeResponse(chunk, state) { // Strip the Claude OAuth prefix from an incoming tool name (if any). const incomingName = (() => { let n = tc.function?.name || ""; - n = caseInsensitiveToolNameLookup(n, state.toolNameMap) ?? n; if (n.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) n = n.slice(CLAUDE_OAUTH_TOOL_PREFIX.length); - return n; + return restoreClaudeToolName(n, state.toolNameMap); })(); // A tool call is identified by its id. Some OpenAI-compatible upstreams @@ -295,8 +352,9 @@ export function openaiToClaudeResponse(chunk, state) { stopThinkingBlock(state, results); stopTextBlock(state, results); + const sanitizedId = sanitizeToolId(tc.id); state.toolCalls.set(idx, { - id: tc.id, + id: sanitizedId, name: incomingName, blockIndex: state.nextBlockIndex++, // Shimmed tools buffer their raw args and emit a single corrected @@ -310,7 +368,7 @@ export function openaiToClaudeResponse(chunk, state) { const toolInfo = state.toolCalls.get(idx); if (toolInfo) { // Capture a late-arriving id or name (streamed after the initial chunk). - if (tc.id && !toolInfo.id) toolInfo.id = tc.id; + if (tc.id && !toolInfo.id) toolInfo.id = sanitizeToolId(tc.id); if (incomingName && !toolInfo.startEmitted && !toolInfo.name) { toolInfo.name = incomingName; toolInfo.shimmed = hasToolCallShim(incomingName); @@ -428,8 +486,6 @@ export function openaiToClaudeResponse(chunk, state) { content_block: { type: "tool_use", id: tc.id, - // #9008: prefer request-side original casing; REVERSE_MAP only when - // no map entry exists (#7926 XML TitleCase → lowercase clients). name: restoreClaudeToolName( tc.name, state.toolNameMap instanceof Map ? state.toolNameMap : null diff --git a/open-sse/utils/earlyKeepaliveByteBuffer.ts b/open-sse/utils/earlyKeepaliveByteBuffer.ts new file mode 100644 index 0000000000..510ffcf5b2 --- /dev/null +++ b/open-sse/utils/earlyKeepaliveByteBuffer.ts @@ -0,0 +1,58 @@ +/** + * @file earlyKeepaliveByteBuffer.ts + * @description Bridges bytes withEarlyStreamKeepalive writes directly to the + * client (outside the request handler's own reqLogger) back into that same + * request's persisted call-log artifact. + * + * withEarlyStreamKeepalive wraps a route's handler Promise from OUTSIDE the + * handler's own call tree — it has no reference to the reqLogger the handler + * creates deep inside chatCore.ts, and by the time that reqLogger exists the + * keepalive/startup frames may already be written. A shared correlationId + * (threaded by the route as handleChat's 4th positional arg, and separately + * into withEarlyStreamKeepalive's options) is the only thing both sides + * share, so recordEarlyKeepaliveBytes/takeEarlyKeepaliveBytes key on that + * instead of trying to pass a live object reference across the boundary. + * + * Entries are consumed once (chatCore/attemptLogging.ts calls + * takeEarlyKeepaliveBytes exactly when it assembles the final call-log + * payload) and swept on a TTL so a request that never reaches that point + * (aborted, detailed logging disabled, a route that never wires this up) + * cannot leak buffered bytes forever. + */ + +const MAX_ITEMS_PER_CORRELATION = 200; +const ENTRY_TTL_MS = 10 * 60 * 1000; + +type BufferEntry = { chunks: string[]; createdAt: number }; + +const buffers = new Map(); + +function sweepExpired(): void { + const cutoff = Date.now() - ENTRY_TTL_MS; + for (const [correlationId, entry] of buffers) { + if (entry.createdAt < cutoff) { + buffers.delete(correlationId); + } + } +} + +export function recordEarlyKeepaliveBytes(correlationId: string, chunk: string): void { + if (!correlationId || !chunk) return; + sweepExpired(); + let entry = buffers.get(correlationId); + if (!entry) { + entry = { chunks: [], createdAt: Date.now() }; + buffers.set(correlationId, entry); + } + if (entry.chunks.length < MAX_ITEMS_PER_CORRELATION) { + entry.chunks.push(chunk); + } +} + +export function takeEarlyKeepaliveBytes(correlationId: string): string[] { + sweepExpired(); + const entry = buffers.get(correlationId); + if (!entry) return []; + buffers.delete(correlationId); + return entry.chunks; +} diff --git a/open-sse/utils/earlyStreamKeepalive.ts b/open-sse/utils/earlyStreamKeepalive.ts index fe9964e477..aa7fb63594 100644 --- a/open-sse/utils/earlyStreamKeepalive.ts +++ b/open-sse/utils/earlyStreamKeepalive.ts @@ -32,6 +32,7 @@ */ import { ResponsesOutputIndexStack } from "./responsesOutputIndexStack.ts"; +import { recordEarlyKeepaliveBytes } from "./earlyKeepaliveByteBuffer.ts"; const ENCODER = new TextEncoder(); const KEEPALIVE_FRAME = ENCODER.encode(": keepalive\n\n"); @@ -207,6 +208,19 @@ export type EarlyStreamKeepaliveOptions = { * instead — see the doc comment on the default ERROR_FRAME above for why. */ errorFrame?: Uint8Array; + /** + * Request correlation id, threaded from the route's own handleChat(..., + * correlationId) call. When set, every byte this wrapper writes to the + * client directly (startup frame, periodic keepalive ticks, and any + * in-band error frame) — everything except the verbatim-forwarded real + * response body, which the handler's own reqLogger already captures — is + * recorded via earlyKeepaliveByteBuffer and merged into this same + * request's call-log streamChunks.client by + * chatCore/attemptLogging.ts, so the persisted artifact reflects what + * actually went out on the wire instead of only what the inner handler + * produced. Omit to leave today's behavior unchanged (no recording). + */ + correlationId?: string; }; /** @@ -234,6 +248,15 @@ export async function withEarlyStreamKeepalive( // Responses) — derived from errorFrame itself so the dynamic real-upstream-body case // below stays consistent with the static default-message case without a second option. const errorFrameUsesNamedEvent = new TextDecoder().decode(errorFrame).startsWith("event:"); + const correlationId = options.correlationId; + const frameDecoder = correlationId ? new TextDecoder() : null; + // Records every direct-to-client write EXCEPT the forwarded real response + // body — that one is already captured by the handler's own reqLogger, so + // recording it again here would duplicate it in the persisted artifact. + const recordClientBytes = (chunk: Uint8Array): void => { + if (!correlationId || !frameDecoder) return; + recordEarlyKeepaliveBytes(correlationId, frameDecoder.decode(chunk)); + }; // Settle into a tagged result so neither race branch leaves an unhandled // rejection when the threshold timer wins. @@ -272,6 +295,7 @@ export async function withEarlyStreamKeepalive( if (stopped) return; try { controller.enqueue(keepaliveFrame); + recordClientBytes(keepaliveFrame); } catch { stopped = true; clearInterval(interval); @@ -286,6 +310,7 @@ export async function withEarlyStreamKeepalive( // sub-interval gap, defeating the keepalive for exactly the case it targets. try { controller.enqueue(startupFrame); + recordClientBytes(startupFrame); } catch { /* consumer already gone */ } @@ -326,6 +351,7 @@ export async function withEarlyStreamKeepalive( if (result.status === "rejected") { // Handler rejected — emit a generic error frame (never the raw error/stack). controller.enqueue(errorFrame); + recordClientBytes(errorFrame); } else { const response = result.response; const contentType = (response.headers.get("content-type") || "").toLowerCase(); @@ -352,6 +378,7 @@ export async function withEarlyStreamKeepalive( // the stream end naturally. if (bytesForwarded === 0) { controller.enqueue(errorFrame); + recordClientBytes(errorFrame); } } } else { @@ -366,7 +393,9 @@ export async function withEarlyStreamKeepalive( const framed = errorFrameUsesNamedEvent ? `event: error\ndata: ${dataLine}\n\n` : `data: ${dataLine}\n\n`; - controller.enqueue(ENCODER.encode(framed)); + const framedBytes = ENCODER.encode(framed); + controller.enqueue(framedBytes); + recordClientBytes(framedBytes); } } } catch { @@ -374,6 +403,7 @@ export async function withEarlyStreamKeepalive( if (!aborted) { try { controller.enqueue(errorFrame); + recordClientBytes(errorFrame); } catch { /* consumer gone */ } diff --git a/open-sse/utils/opencodeHeaders.ts b/open-sse/utils/opencodeHeaders.ts index 81d9a9f79f..398d53c011 100644 --- a/open-sse/utils/opencodeHeaders.ts +++ b/open-sse/utils/opencodeHeaders.ts @@ -1,5 +1,6 @@ import { randomUUID } from "crypto"; import { setUserAgentHeader } from "../executors/base.ts"; +import { generateSessionId } from "../services/sessionManager.ts"; /** * Header keys that are forwarded from the client to the upstream provider. @@ -51,6 +52,10 @@ function findHeader(headers: Record, name: string): string | und * that is not already the OpenCode CLI (e.g. curl/8.5.0) is REPLACED with the * synthesized CLI UA, because opencode.ai's free tier rejects generic client UAs * from datacenter IPs with FreeUsageLimitError 429. (#5997, follow-up #10229) + * @param options.sessionBody - Request body fields used to generate a + * conversation-stable session fingerprint (model, system, messages, tools). + * When provided, x-opencode-session is a deterministic hash instead of a random + * UUID, so upstream prompt caching hits across requests in the same conversation. */ export function forwardOpencodeClientHeaders( headers: Record, @@ -58,6 +63,12 @@ export function forwardOpencodeClientHeaders( options?: { synthesizeRequestId?: boolean; cliDefaults?: { userAgent: string; client: string; project: string }; + sessionBody?: { + model?: string; + system?: unknown; + messages?: Array<{ role?: string; content?: unknown }>; + tools?: Array<{ name?: string; function?: { name?: string } }>; + }; } ): void { // 1. Forward User-Agent @@ -98,7 +109,7 @@ export function forwardOpencodeClientHeaders( // 4. OpencodeExecutor-only: synthesize the OpenCode CLI identity Cloudflare expects // on VPS egress, for any key the client did not supply (#5997). if (options?.cliDefaults) { - applyCliDefaults(headers, options.cliDefaults); + applyCliDefaults(headers, options.cliDefaults, options.sessionBody); } } @@ -113,7 +124,13 @@ export function forwardOpencodeClientHeaders( */ function applyCliDefaults( headers: Record, - cliDefaults: { userAgent: string; client: string; project: string } + cliDefaults: { userAgent: string; client: string; project: string }, + sessionBody?: { + model?: string; + system?: unknown; + messages?: Array<{ role?: string; content?: unknown }>; + tools?: Array<{ name?: string; function?: { name?: string } }>; + } ): void { const existingUa = headers["User-Agent"] || headers["user-agent"]; const clientUaIsCliLike = @@ -124,5 +141,6 @@ function applyCliDefaults( headers["x-opencode-client"] ||= cliDefaults.client; headers["x-opencode-project"] ||= cliDefaults.project; headers["x-opencode-request"] ||= randomUUID(); - headers["x-opencode-session"] ||= randomUUID(); + headers["x-opencode-session"] ||= + generateSessionId(sessionBody ?? null) || randomUUID(); } diff --git a/open-sse/utils/requestLogger.ts b/open-sse/utils/requestLogger.ts index ea2cc6ff0c..eb7709a835 100644 --- a/open-sse/utils/requestLogger.ts +++ b/open-sse/utils/requestLogger.ts @@ -81,6 +81,7 @@ function maskSensitiveHeaders(headers: HeaderInput): Record { "storage-state", "storagestate", "capability", + "x-omniroute-lease-owner", ]; for (const key of Object.keys(masked)) { @@ -89,6 +90,10 @@ function maskSensitiveHeaders(headers: HeaderInput): Record { if (lowerKey.startsWith("x-ratelimit-")) { continue; } + if (lowerKey === "x-omniroute-lease-owner") { + masked[key] = "[REDACTED]"; + continue; + } if (!sensitiveKeys.some((candidate) => lowerKey.includes(candidate))) { continue; } diff --git a/open-sse/utils/responsesToolHandoff.ts b/open-sse/utils/responsesToolHandoff.ts new file mode 100644 index 0000000000..e9d80a954c --- /dev/null +++ b/open-sse/utils/responsesToolHandoff.ts @@ -0,0 +1,132 @@ +type CompletedToolItem = { + keys: string[]; + type: "function_call" | "custom_tool_call"; + value: string; +}; + +function getResponsesEventKeys( + payload: Record, + item?: Record +): string[] { + const keys = new Set(); + const addStringKey = (prefix: string, value: unknown) => { + if (typeof value === "string" && value.trim()) keys.add(`${prefix}:${value.trim()}`); + }; + const addIndexKey = (value: unknown) => { + if (typeof value === "number" && Number.isInteger(value) && value >= 0) { + keys.add(`index:${value}`); + } + }; + + addStringKey("item", payload.item_id); + addStringKey("call", payload.call_id); + addIndexKey(payload.output_index); + if (item) { + addStringKey("item", item.id); + addStringKey("call", item.call_id); + } + return [...keys]; +} + +/** + * Codex can start its next turn as soon as it receives a complete client-side + * tool call, closing the current HTTP response before response.completed. This + * watcher accepts only a matching done-payload plus a completed tool item; + * ordinary message/reasoning items and partial calls never qualify. + */ +export function createCompletedResponsesToolHandoffWatcher() { + let buffer = ""; + let completed = false; + const functionArgumentsDone = new Map(); + const customToolInputDone = new Map(); + const completedToolItems: CompletedToolItem[] = []; + + const matchesDonePayload = (item: CompletedToolItem): boolean => { + const doneValues = item.type === "function_call" ? functionArgumentsDone : customToolInputDone; + return item.keys.some((key) => doneValues.get(key) === item.value); + }; + + const evaluate = () => { + completed = completed || completedToolItems.some(matchesDonePayload); + }; + + const notePayload = (payload: Record, eventType: string) => { + if ( + eventType === "response.function_call_arguments.done" && + typeof payload.arguments === "string" + ) { + for (const key of getResponsesEventKeys(payload)) { + functionArgumentsDone.set(key, payload.arguments); + } + evaluate(); + return; + } + + if (eventType === "response.custom_tool_call_input.done" && typeof payload.input === "string") { + for (const key of getResponsesEventKeys(payload)) { + customToolInputDone.set(key, payload.input); + } + evaluate(); + return; + } + + if (eventType !== "response.output_item.done") return; + const item = + payload.item && typeof payload.item === "object" && !Array.isArray(payload.item) + ? (payload.item as Record) + : null; + if (!item) return; + if (item.type !== "function_call" && item.type !== "custom_tool_call") return; + if (typeof item.call_id !== "string" || !item.call_id.trim()) return; + if (typeof item.name !== "string" || !item.name.trim()) return; + if (item.status !== undefined && item.status !== "completed") return; + + const valueKey = item.type === "function_call" ? "arguments" : "input"; + const value = item[valueKey]; + if (typeof value !== "string") return; + const keys = getResponsesEventKeys(payload, item); + if (keys.length === 0) return; + + completedToolItems.push({ keys, type: item.type, value }); + if (completedToolItems.length > 32) completedToolItems.shift(); + evaluate(); + }; + + const noteFrame = (frame: string) => { + let eventType = ""; + const dataLines: string[] = []; + for (const rawLine of frame.split(/\r?\n/)) { + const line = rawLine.trimStart(); + if (line.startsWith("event:")) { + eventType = line.slice("event:".length).trim(); + } else if (line.startsWith("data:")) { + dataLines.push(line.slice("data:".length).trimStart()); + } + } + if (dataLines.length === 0) return; + + try { + const parsed = JSON.parse(dataLines.join("\n")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return; + const payload = parsed as Record; + notePayload(payload, typeof payload.type === "string" ? payload.type : eventType); + } catch { + // A partial/malformed frame is not evidence of a completed tool handoff. + } + }; + + return { + note(text: string): boolean { + if (completed) return true; + buffer += text; + let boundary = /\r?\n\r?\n/.exec(buffer); + while (boundary) { + noteFrame(buffer.slice(0, boundary.index)); + buffer = buffer.slice(boundary.index + boundary[0].length); + boundary = /\r?\n\r?\n/.exec(buffer); + } + if (buffer.length > 65_536) buffer = buffer.slice(-65_536); + return completed; + }, + }; +} diff --git a/open-sse/utils/sseHeartbeat.ts b/open-sse/utils/sseHeartbeat.ts index 5fb415ea8b..bdb355b96e 100644 --- a/open-sse/utils/sseHeartbeat.ts +++ b/open-sse/utils/sseHeartbeat.ts @@ -71,16 +71,17 @@ const HEARTBEAT_ENCODER = new TextEncoder(); /** * Whether OmniRoute may emit SSE `:` comment lines (e.g. the `: keepalive` heartbeat). * Some strict OpenAI-compatible clients parse every SSE line as JSON and crash on `:` comments. - * Set OMNIROUTE_SSE_COMMENTS=off to suppress comment-shaped heartbeats (they become a no-op). - * Defaults to enabled for backward compatibility. + * Set OMNIROUTE_SSE_COMMENTS=on to enable comment-shaped heartbeats and telemetry trailers. + * #10524: defaults to disabled — strict SSE clients (WorkBuddy, etc.) break on `: x-omniroute-*` + * comment lines. Operators who want the telemetry can opt in with OMNIROUTE_SSE_COMMENTS=on. */ export function sseCommentsEnabled(): boolean { // SSR/edge safety: `process` is not defined in Workers/Deno/edge runtimes. - if (typeof process === "undefined") return true; + if (typeof process === "undefined") return false; const v = process.env.OMNIROUTE_SSE_COMMENTS; - if (v === undefined || v === "") return true; + if (v === undefined || v === "") return false; const normalized = v.trim().toLowerCase(); - return normalized !== "off" && normalized !== "false" && normalized !== "0" && normalized !== "no"; + return normalized === "on" || normalized === "true" || normalized === "1" || normalized === "yes"; } export function createSseHeartbeatTransform({ diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index acc85e2c43..e6547bb3e5 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -78,6 +78,7 @@ import { caseInsensitiveToolNameLookup, restoreOpenAIToolNames, } from "../translator/helpers/toolCallHelper.ts"; +import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts"; import { normalizeFinalOpenAIStreamChunk } from "./openAIStreamChunk.ts"; import { collectClaudeDelta } from "./streamClaudeDelta.ts"; @@ -576,17 +577,16 @@ function getOpenAIIntermediateChunks(value: unknown): unknown[] { return Array.isArray(candidate) ? candidate : []; } -function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: unknown): boolean { - if (!(toolNameMap instanceof Map)) return false; - if (!parsed || typeof parsed !== "object") return false; - +export function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: unknown): boolean { const block = parsed.content_block && typeof parsed.content_block === "object" ? (parsed.content_block as JsonRecord) : null; if (!block || block.type !== "tool_use" || typeof block.name !== "string") return false; - const restoredName = caseInsensitiveToolNameLookup(block.name, toolNameMap) ?? block.name; + const map = toolNameMap instanceof Map ? toolNameMap : null; + const restoredName = restoreClaudeToolName(block.name, map); + if (restoredName === block.name) return false; block.name = restoredName; return true; @@ -830,7 +830,15 @@ export function createSSEStream(options: StreamOptions = {}) { let idleTimer: ReturnType | null = null; let streamTimedOut = false; const claudeEmptyResponseLifecycle = createClaudeEmptyResponseLifecycle(); - const passthroughEventPrefix = createSSEEventPrefixBuffer(); + // `event:` framing is only part of the SSE protocol for OpenAI Responses API + // and Claude Messages API passthrough; a plain OpenAI Chat-Completions-format + // client has no `event:` field at all, so it is dropped to stop upstream + // control lines (`id:`/`event:`/`retry:`/`:` comments) leaking to the client + // (#10017). + const passthroughEventPrefix = createSSEEventPrefixBuffer({ + forwardEvent: + clientResponseFormat === FORMATS.OPENAI_RESPONSES || clientResponseFormat === FORMATS.CLAUDE, + }); const multilineSseDataLineNormalizer = createSSEDataLineNormalizer(); const clearIdleTimer = () => { diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index 4e7bf382dd..e79c858b13 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -2,6 +2,7 @@ import { trackPendingRequest } from "@/lib/usageDb"; import { STREAM_IDLE_TIMEOUT_MS } from "../config/constants.ts"; import { FORMATS } from "../translator/formats.ts"; import { PENDING_REQUEST_CLEARED_MARKER } from "./stream.ts"; +import { createCompletedResponsesToolHandoffWatcher } from "./responsesToolHandoff.ts"; import { createStreamContentWatcher, type StreamContentWatcher } from "./streamReadiness.ts"; // Stream handler with disconnect detection - shared for all providers @@ -36,6 +37,8 @@ type StreamControllerOptions = { connectionId?: string | null; clientResponseFormat?: string | null; clientAbortSignal?: AbortSignal | null; + allowCompletedToolHandoffGrace?: boolean; + clientDisconnectGracePeriodMs?: number; }; type StreamController = ReturnType; @@ -238,11 +241,15 @@ export function createStreamController({ connectionId, clientResponseFormat, clientAbortSignal, + allowCompletedToolHandoffGrace = false, + clientDisconnectGracePeriodMs = 0, }: StreamControllerOptions = {}) { const abortController = new AbortController(); const startTime = Date.now(); let disconnected = false; let clientTerminalSeen = false; + let completedToolHandoffSeen = false; + let completedToolHandoffDrain: (() => void) | null = null; let pendingRequestCleared = false; let cleanupClientAbortSignal: (() => void) | null = null; @@ -316,7 +323,16 @@ export function createStreamController({ // fire when the client aborts mid-stream, so we must clean up here. clearPendingRequest(); - abortController.abort(reason); + const deferUpstreamAbort = + allowCompletedToolHandoffGrace && + clientDisconnectGracePeriodMs > 0 && + completedToolHandoffSeen && + completedToolHandoffDrain !== null; + if (deferUpstreamAbort) { + completedToolHandoffDrain?.(); + } else { + abortController.abort(reason); + } onDisconnect?.({ reason, duration: Date.now() - startTime }); }, @@ -334,6 +350,20 @@ export function createStreamController({ clientTerminalSeen = true; }, + markCompletedToolHandoffSeen: () => { + completedToolHandoffSeen = true; + }, + + registerCompletedToolHandoffDrain: (drain: () => void) => { + completedToolHandoffDrain = drain; + }, + + shouldDeferCompletedToolHandoff: () => + allowCompletedToolHandoffGrace && + clientDisconnectGracePeriodMs > 0 && + completedToolHandoffSeen && + completedToolHandoffDrain !== null, + // Call on error handleError: (error: unknown) => { cleanupClientAbortListener(); @@ -387,6 +417,7 @@ export function createStreamController({ abortController.abort(); }, clientResponseFormat, + clientDisconnectGracePeriodMs, }; if (clientAbortSignal && typeof clientAbortSignal.addEventListener === "function") { @@ -556,9 +587,38 @@ export function createDisconnectAwareStream(transformStream, streamController) { const terminalDecoder = new TextDecoder(); const contentDecoder = new TextDecoder(); const contentWatcher = createStreamContentWatcher(); + const completedToolHandoffWatcher = createCompletedResponsesToolHandoffWatcher(); + const toolHandoffDecoder = new TextDecoder(); let terminalTail = ""; let clientTerminalSeen = false; let bytesWereForwarded = false; + let completedToolHandoffDrainStarted = false; + + const drainCompletedToolHandoff = () => { + if (completedToolHandoffDrainStarted) return; + completedToolHandoffDrainStarted = true; + const gracePeriodMs = Math.max(0, Number(streamController.clientDisconnectGracePeriodMs) || 0); + const timeoutReason = "completed_tool_handoff_grace_expired"; + const timeout = setTimeout(() => { + streamController.abort(); + void Promise.allSettled([reader.cancel(timeoutReason), writer.abort(timeoutReason)]); + }, gracePeriodMs); + + void (async () => { + try { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + streamController.handleComplete(); + } catch (error) { + streamController.handleError(error); + } finally { + clearTimeout(timeout); + } + })(); + }; + streamController.registerCompletedToolHandoffDrain?.(drainCompletedToolHandoff); const noteClientChunk = (chunk: unknown) => { if (!(chunk instanceof Uint8Array)) return; @@ -566,6 +626,12 @@ export function createDisconnectAwareStream(transformStream, streamController) { // Runs past clientTerminalSeen: the frame that carries the terminal marker // can carry the only content too, and #8649 needs the whole stream scanned. contentWatcher.note(contentDecoder.decode(chunk, { stream: true })); + if ( + isResponsesClientFormat(streamController.clientResponseFormat) && + completedToolHandoffWatcher.note(toolHandoffDecoder.decode(chunk, { stream: true })) + ) { + streamController.markCompletedToolHandoffSeen?.(); + } if (clientTerminalSeen) return; terminalTail += terminalDecoder.decode(chunk, { stream: true }); @@ -676,11 +742,14 @@ export function createDisconnectAwareStream(transformStream, streamController) { }, async cancel(reason) { + const deferCompletedToolHandoff = + streamController.shouldDeferCompletedToolHandoff?.() === true; if (clientTerminalSeen) { streamController.handleComplete(); } else { streamController.handleDisconnect(reason || "cancelled"); } + if (deferCompletedToolHandoff) return; await Promise.allSettled([reader.cancel(reason), writer.abort(reason)]); }, }, diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index d9fb78415b..db8c656d1d 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -213,9 +213,15 @@ export function createSSEDataLineNormalizer(): SSEDataLineNormalizer { }; } -export function createSSEEventPrefixBuffer(): SSEEventPrefixBuffer { +export function createSSEEventPrefixBuffer(options?: { forwardEvent?: boolean }): SSEEventPrefixBuffer { let lines: string[] = []; let emitted = false; + // The `event:` line is only part of the SSE framing for protocols that define + // it (OpenAI Responses API, Claude Messages API). For a plain OpenAI + // Chat-Completions-format client there is no `event:` field at all, so it must + // not be forwarded. Defaults to true to preserve prior behavior for client + // formats that declare no explicit preference (#10017). + const forwardEvent = options?.forwardEvent !== false; const hasUnemitted = () => lines.length > 0 && !emitted; const prefix = (output: string) => { if (!hasUnemitted()) return output; @@ -241,6 +247,14 @@ export function createSSEEventPrefixBuffer(): SSEEventPrefixBuffer { return line.startsWith("data:") ? prefix(output) : output; }, remember(line) { + const trimmed = line.trim(); + // `id:`/`retry:` and bare `:` comment lines are not part of any of the + // OpenAI Chat-Completions, OpenAI Responses, or Claude Messages SSE + // protocols — never buffer (and thus never re-forward) them (#10017). + if (/^(?::|id:|retry:)/i.test(trimmed)) return; + // `event:` framing is only forwarded for protocols that define it; drop it + // for plain OpenAI Chat-Completions-format clients. + if (/^event:/i.test(trimmed) && !forwardEvent) return; lines.push(line); emitted = false; }, diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index b2360f7d58..034c34d93f 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -12,6 +12,62 @@ import { } from "@/lib/usage/tokenAccounting"; import { FORMATS } from "../translator/formats.ts"; +/** Nested `*_tokens_details` containers ({ cached_tokens, reasoning_tokens, … }). */ +interface UsageTokenDetail { + cached_tokens?: number; + reasoning_tokens?: number; + thinking_tokens?: number; + [field: string]: unknown; +} + +/** + * Loosely-shaped usage object accepted from any provider wire format. + * Declared fields cover the numeric counters this module reads/writes; + * everything else passes through untouched via the index signature. + */ +export interface UsageLike { + estimated?: boolean; + input_tokens?: number; + output_tokens?: number; + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + cached_tokens?: number; + no_cache_tokens?: number; + reasoning_tokens?: number; + cost_in_usd_ticks?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + prompt_cache_hit_tokens?: number; + prompt_cache_miss_tokens?: number; + promptTokenCount?: number; + candidatesTokenCount?: number; + totalTokenCount?: number; + cachedContentTokenCount?: number; + thoughtsTokenCount?: number; + context_budget_input_tokens?: number; + context_budget_prompt_tokens?: number; + context_budget_total_tokens?: number; + prompt_tokens_details?: UsageTokenDetail; + input_tokens_details?: UsageTokenDetail; + completion_tokens_details?: UsageTokenDetail; + output_tokens_details?: UsageTokenDetail; + [field: string]: unknown; +} + +/** SSE/JSON chunk shapes this module inspects for embedded usage containers. */ +interface UsagePayloadLike { + type?: string; + done?: boolean; + prompt_eval_count?: number; + eval_count?: number; + usage?: UsageLike; + usageMetadata?: UsageLike; + message?: { usage?: UsageLike; [field: string]: unknown }; + response?: { usage?: UsageLike; usageMetadata?: UsageLike; [field: string]: unknown }; + [field: string]: unknown; +} + // ANSI color codes export const COLORS = { reset: "\x1b[0m", @@ -127,7 +183,7 @@ function getTimeString() { * @param {object} usage - Usage object (supported format) * @returns {object} Usage with context_budget_* fields added (metering fields unchanged) */ -export function addBufferToUsage(usage) { +export function addBufferToUsage(usage: UsageLike | null | undefined) { if (!usage || typeof usage !== "object") return usage; // Heuristic estimates (web/cookie providers with no upstream metering) should @@ -164,7 +220,7 @@ export function addBufferToUsage(usage) { return result; } -export function filterUsageForFormat(usage, targetFormat) { +export function filterUsageForFormat(usage: UsageLike | null | undefined, targetFormat: string) { if (!usage || typeof usage !== "object") return usage; // Cross-map between Claude-style and OpenAI-style field names before filtering. @@ -211,8 +267,8 @@ export function filterUsageForFormat(usage, targetFormat) { } // Helper to pick only defined fields from usage - const pickFields = (fields) => { - const filtered = {}; + const pickFields = (fields: string[]) => { + const filtered: Record = {}; for (const field of fields) { if (convertedUsage[field] !== undefined) { filtered[field] = convertedUsage[field]; @@ -222,7 +278,7 @@ export function filterUsageForFormat(usage, targetFormat) { }; // Define allowed fields for each format - const formatFields = { + const formatFields: Record = { [FORMATS.CLAUDE]: [ "input_tokens", "output_tokens", @@ -312,7 +368,7 @@ const REMOTE_CONTEXT_REFERENCE_KEYS = new Set([ "videoUrl", ]); -function hasValue(value): boolean { +function hasValue(value: unknown): boolean { if (value === null || value === undefined || value === false) return false; if (typeof value === "string") return value.trim().length > 0; if (Array.isArray(value)) return value.length > 0; @@ -320,7 +376,7 @@ function hasValue(value): boolean { return true; } -function hasRemoteContextReference(value, depth = 0): boolean { +function hasRemoteContextReference(value: unknown, depth = 0): boolean { if (!value || typeof value !== "object" || depth > 8) return false; if (Array.isArray(value)) { @@ -338,7 +394,7 @@ function hasRemoteContextReference(value, depth = 0): boolean { return false; } -function getSerializedBodyBytes(body): number | null { +function getSerializedBodyBytes(body: unknown): number | null { if (!body || typeof body !== "object" || hasRemoteContextReference(body)) return null; try { const serialized = JSON.stringify(body); @@ -349,7 +405,7 @@ function getSerializedBodyBytes(body): number | null { } } -function tokenNumber(value): number { +function tokenNumber(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } @@ -357,7 +413,7 @@ function tokenNumber(value): number { * Return true when a provider-reported input count is plausible for this request. * `null`/unserializable bodies and server-side context references fail open. */ -export function isInputTokenCountPlausible(inputTokens, body): boolean { +export function isInputTokenCountPlausible(inputTokens: unknown, body: unknown): boolean { if (typeof inputTokens !== "number" || !Number.isFinite(inputTokens) || inputTokens < 0) { return false; } @@ -368,7 +424,7 @@ export function isInputTokenCountPlausible(inputTokens, body): boolean { return inputTokens <= maximum; } -function resolveUsageFormat(usage, targetFormat) { +function resolveUsageFormat(usage: UsageLike | null | undefined, targetFormat: string | null) { if (targetFormat === FORMATS.CLAUDE) return FORMATS.CLAUDE; if (targetFormat === FORMATS.GEMINI || targetFormat === FORMATS.ANTIGRAVITY) { return FORMATS.GEMINI; @@ -391,7 +447,7 @@ function resolveUsageFormat(usage, targetFormat) { return FORMATS.OPENAI; } -function getReportedInputTokens(usage, format): number { +function getReportedInputTokens(usage: UsageLike, format: string): number { if (format === FORMATS.CLAUDE) { return ( tokenNumber(usage.input_tokens) + @@ -408,7 +464,7 @@ function getReportedInputTokens(usage, format): number { return tokenNumber(usage.prompt_tokens ?? usage.input_tokens); } -function clearCachedTokenDetail(value) { +function clearCachedTokenDetail(value: T): T { if (!value || typeof value !== "object" || Array.isArray(value)) return value; const result = { ...value }; if (result.cached_tokens !== undefined) result.cached_tokens = 0; @@ -419,7 +475,11 @@ function clearCachedTokenDetail(value) { * Replace only physically implausible provider input/cache usage with the local * request estimate. Valid usage is returned by reference and remains untouched. */ -export function sanitizeProviderUsageForRequest(usage, body, targetFormat = null) { +export function sanitizeProviderUsageForRequest( + usage: UsageLike | null | undefined, + body: unknown, + targetFormat: string | null = null +) { if (!usage || typeof usage !== "object" || Array.isArray(usage)) return usage; const format = resolveUsageFormat(usage, targetFormat); @@ -475,12 +535,20 @@ export function sanitizeProviderUsageForRequest(usage, body, targetFormat = null * Sanitize the usage container used by native provider responses/SSE events. * Returns true only when the payload was changed and must be re-serialized. */ -export function sanitizeUsagePayloadForRequest(payload, body, targetFormat = null): boolean { +export function sanitizeUsagePayloadForRequest( + payload: UsagePayloadLike | null | undefined, + body: unknown, + targetFormat: string | null = null +): boolean { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; - const replaceUsage = (owner, key, format) => { + const replaceUsage = ( + owner: Record | null | undefined, + key: string, + format: string | null + ) => { if (!owner || typeof owner !== "object" || !owner[key]) return false; - const sanitized = sanitizeProviderUsageForRequest(owner[key], body, format); + const sanitized = sanitizeProviderUsageForRequest(owner[key] as UsageLike, body, format); if (sanitized === owner[key]) return false; owner[key] = sanitized; return true; @@ -511,11 +579,11 @@ export function sanitizeUsagePayloadForRequest(payload, body, targetFormat = nul /** * Normalize usage object - ensure all values are valid numbers */ -export function normalizeUsage(usage) { +export function normalizeUsage(usage: UsageLike | null | undefined) { if (!usage || typeof usage !== "object" || Array.isArray(usage)) return null; const normalized: Record = {}; - const assignNumber = (key, value) => { + const assignNumber = (key: string, value: unknown) => { if (value === undefined || value === null) return; const numeric = Number(value); if (Number.isFinite(numeric)) normalized[key] = numeric; @@ -551,7 +619,7 @@ export function normalizeUsage(usage) { * Valid = has at least one token field with value > 0 * Invalid = empty object {}, null, undefined, no token fields, or all zeros */ -export function hasValidUsage(usage) { +export function hasValidUsage(usage: UsageLike | null | undefined) { if (!usage || typeof usage !== "object") return false; // Check for known token fields with value > 0 @@ -577,7 +645,7 @@ export function hasValidUsage(usage) { /** * Extract usage from supported formats (Claude, OpenAI, Gemini, Responses API) */ -export function extractUsage(chunk) { +export function extractUsage(chunk: UsagePayloadLike | null | undefined) { if (!chunk || typeof chunk !== "object") return null; // Claude/Antigravity streaming: message_start event carries INPUT tokens @@ -715,7 +783,7 @@ const CHARS_PER_TOKEN_SCHEMA = 6; // ~6 chars/token for JSON schemas (more verbo * @param {string} text - Text to estimate tokens for * @returns {number} Estimated token count */ -function estimateTokenCount(text) { +function estimateTokenCount(text: unknown) { if (!text || typeof text !== "string") return 0; // Count CJK ideographs separately — each is roughly 1 token @@ -743,22 +811,23 @@ function estimateTokenCount(text) { * for more accurate estimation since JSON schemas are more verbose but * compress into fewer tokens than plain text. */ -export function estimateInputTokens(body) { +export function estimateInputTokens(body: unknown) { if (!body || typeof body !== "object") return 0; + const record = body as Record; try { let toolTokens = 0; let messageTokens = 0; // Separate tool definitions from the rest of the body - if (body.tools && Array.isArray(body.tools)) { - const toolStr = JSON.stringify(body.tools); + if (record.tools && Array.isArray(record.tools)) { + const toolStr = JSON.stringify(record.tools); toolTokens = Math.ceil(toolStr.length / CHARS_PER_TOKEN_SCHEMA); // Estimate messages without tools - const { tools, ...bodyWithoutTools } = body; + const { tools, ...bodyWithoutTools } = record; messageTokens = estimateTokenCount(JSON.stringify(bodyWithoutTools)); } else { - messageTokens = estimateTokenCount(JSON.stringify(body)); + messageTokens = estimateTokenCount(JSON.stringify(record)); } return messageTokens + toolTokens; @@ -772,7 +841,7 @@ export function estimateInputTokens(body) { * Estimate output tokens from content length. * Uses improved heuristic when possible, falls back to length-based estimation. */ -export function estimateOutputTokens(contentLength) { +export function estimateOutputTokens(contentLength: number | null | undefined) { if (!contentLength || contentLength <= 0) return 0; // When we only have a character count, use 4 chars/token with sub-word correction return Math.max(1, Math.ceil(contentLength / 3.5)); @@ -784,7 +853,7 @@ export function estimateOutputTokens(contentLength) { * @param {number} outputTokens - Output/completion tokens * @param {string} targetFormat - Target format from FORMATS */ -export function formatUsage(inputTokens, outputTokens, targetFormat) { +export function formatUsage(inputTokens: number, outputTokens: number, targetFormat: string) { // Claude format uses input_tokens/output_tokens if (targetFormat === FORMATS.CLAUDE) { return addBufferToUsage({ @@ -809,7 +878,11 @@ export function formatUsage(inputTokens, outputTokens, targetFormat) { * @param {number} contentLength - Content length for output token estimation * @param {string} targetFormat - Target format from FORMATS constant */ -export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI) { +export function estimateUsage( + body: unknown, + contentLength: number | null | undefined, + targetFormat: string = FORMATS.OPENAI +) { return formatUsage(estimateInputTokens(body), estimateOutputTokens(contentLength), targetFormat); } @@ -817,8 +890,8 @@ export function estimateUsage(body, contentLength, targetFormat = FORMATS.OPENAI * Log usage with cache info (green color) */ export function logUsage( - provider, - usage, + provider: string | null | undefined, + usage: UsageLike | null | undefined, model: string | null = null, connectionId: string | null = null, apiKeyInfo = null diff --git a/package-lock.json b/package-lock.json index 4769372f63..8af4841897 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,32 +14,32 @@ "packages/browser-pool" ], "dependencies": { - "@aws-sdk/client-bedrock-runtime": "^3.1107.0", + "@aws-sdk/client-bedrock-runtime": "^3.1111.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@huggingface/transformers": "^4.2.0", - "@lobehub/icons": "^5.8.0", + "@lobehub/icons": "^5.16.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", "@toon-format/toon": "^4.1.1", "@types/mdx": "^2.0.13", - "@xyflow/react": "^12.11.1", + "@xyflow/react": "^12.11.3", "axios": "^1.19.0", "bcryptjs": "^3.0.3", "bottleneck": "^2.19.5", "clsx": "^2.1.1", "commander": "^15.0.0", - "cron-parser": "^5.8.1", + "cron-parser": "^5.10.0", "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.14.3", - "fumadocs-ui": "^16.14.3", + "fumadocs-core": "^16.14.4", + "fumadocs-ui": "^16.14.4", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", @@ -47,22 +47,22 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.8", - "js-yaml": "^5.2.3", + "js-yaml": "^5.3.0", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.21.0", "marked": "^18.0.9", "marked-terminal": "^7.3.0", - "material-symbols": "^0.45.10", + "material-symbols": "^0.46.0", "mermaid": "^11.15.0", "monaco-editor": "^0.56.0", - "next": "16.3.0", + "next": "16.3.1", "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", - "open": "^11.0.0", + "open": "^11.0.1", "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", @@ -78,9 +78,9 @@ "safe-regex": "^2.1.1", "selfsigned": "^5.5.0", "sharp": "^0.35.3", - "smol-toml": "1.7.2", + "smol-toml": "1.8.0", "socks": "^2.8.7", - "sql.js": "^1.14.1", + "sql.js": "^1.14.2", "tailwind-merge": "^3.6.0", "tsx": "^4.23.12", "turndown": "7.2.4", @@ -92,24 +92,24 @@ "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", "zod": "^4.4.3", - "zustand": "^5.0.13" + "zustand": "^5.0.15" }, "bin": { "omniroute": "bin/omniroute.mjs", "omniroute-reset-password": "bin/reset-password.mjs" }, "devDependencies": { - "@axe-core/playwright": "^4.11.3", - "@cyclonedx/cyclonedx-npm": "6.0.0", + "@axe-core/playwright": "^4.13.0", + "@cyclonedx/cyclonedx-npm": "6.0.1", "@playwright/test": "^1.62.1", "@size-limit/file": "^13.0.3", - "@stryker-mutator/core": "^9.6.1", - "@stryker-mutator/tap-runner": "^9.6.1", + "@stryker-mutator/core": "^10.0.0", + "@stryker-mutator/tap-runner": "^10.0.0", "@tailwindcss/postcss": "^4.3.0", - "@testing-library/jest-dom": "^7.0.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^9.6.0", - "@types/bun": "latest", + "@types/bun": "*", "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", @@ -123,22 +123,22 @@ "ctrf": "^0.2.1", "dpdm": "^4.3.0", "eslint": "^9.39.4", - "eslint-config-next": "16.3.0", + "eslint-config-next": "16.3.1", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", - "fumadocs-mdx": "^15.2.2", + "fumadocs-mdx": "^15.2.3", "glob": "^13.0.6", "httpyac": "^6.16.7", "husky": "^9.1.7", - "jscpd": "^4.2.5", + "jscpd": "^4.3.0", "jsdom": "^30.0.1", "junit-to-ctrf": "^0.0.14", - "knip": "^6.32.0", + "knip": "^6.32.2", "license-checker-rseidelsohn": "^5.0.1", "lint-staged": "^17.3.0", - "lockfile-lint": "^5.0.0", + "lockfile-lint": "^5.0.1", "node-loader": "^2.1.0", - "opencode-ai": "1.18.15", + "opencode-ai": "1.18.18", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.9.6", "promptfoo": "^0.122.0", @@ -146,7 +146,7 @@ "tailwindcss": "^4.3.0", "type-coverage": "^2.30.1", "typescript": "^6.0.3", - "typescript-eslint": "^8.66.0", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.7", "wait-on": "^9.1.0", "wtfnode": "^0.10.1" @@ -610,18 +610,18 @@ } }, "node_modules/@aws-sdk/client-bedrock-runtime": { - "version": "3.1107.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1107.0.tgz", - "integrity": "sha512-qeaRwHqwPx7OU3d3zuI4Kivtq3vF3WL4w83vuWpZosmbQzgQCka8jsMoHhIVr1ewmuTekYhcPsYY32TqjC6HcA==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1111.0.tgz", + "integrity": "sha512-+HHZEehmRaGo1F7YVACor/xARM+m1j8YloFaXfoWn4TIPRojUQId/wyItH2jXyro8JoL78CRRZSI1Z8StX0ldQ==", "license": "Apache-2.0", "dependencies": { - "@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", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-node": "^3.972.80", + "@aws-sdk/eventstream-handler-node": "^3.972.33", + "@aws-sdk/middleware-eventstream": "^3.972.28", + "@aws-sdk/middleware-websocket": "^3.972.51", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -632,23 +632,6 @@ "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" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@aws-sdk/client-s3": { "version": "3.1086.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1086.0.tgz", @@ -695,13 +678,13 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", - "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "version": "3.977.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.8.tgz", + "integrity": "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", - "@aws-sdk/xml-builder": "^3.972.38", + "@aws-sdk/types": "^3.974.4", + "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", @@ -714,13 +697,13 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "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==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.69.tgz", + "integrity": "sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -730,13 +713,13 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "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==", + "version": "3.972.71", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.71.tgz", + "integrity": "sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -748,20 +731,20 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "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==", + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.14.tgz", + "integrity": "sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==", "license": "Apache-2.0", "dependencies": { - "@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", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-login": "^3.972.76", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -772,14 +755,14 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "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==", + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.76.tgz", + "integrity": "sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -789,18 +772,18 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "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==", + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.80.tgz", + "integrity": "sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==", "license": "Apache-2.0", "dependencies": { - "@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", + "@aws-sdk/credential-provider-env": "^3.972.69", + "@aws-sdk/credential-provider-http": "^3.972.71", + "@aws-sdk/credential-provider-ini": "^3.973.14", + "@aws-sdk/credential-provider-process": "^3.972.69", + "@aws-sdk/credential-provider-sso": "^3.973.13", + "@aws-sdk/credential-provider-web-identity": "^3.972.75", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.16.1", @@ -811,13 +794,13 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "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==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.69.tgz", + "integrity": "sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -827,15 +810,15 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "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==", + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.13.tgz", + "integrity": "sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==", "license": "Apache-2.0", "dependencies": { - "@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", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/token-providers": "3.1111.0", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -845,14 +828,14 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "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==", + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.75.tgz", + "integrity": "sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -862,12 +845,12 @@ } }, "node_modules/@aws-sdk/eventstream-handler-node": { - "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==", + "version": "3.972.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.33.tgz", + "integrity": "sha512-1Dd5WyEE2Kb3HvY44u7Ob16ST2W6iutOqsQ8Y2hUmsL2mAH/STlGS1dS9h3IOE6L7Ld3AR2HzKJ6XeCMOw8Peg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -877,12 +860,12 @@ } }, "node_modules/@aws-sdk/middleware-eventstream": { - "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==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.28.tgz", + "integrity": "sha512-Z1EDXnS01P7H5jVrUx+/dBqV0m7dta7bSxLclkOuDuS93pNNQm0IcT4YLUbuvWKPYNxbI8aTG0p5Br30GSKDgA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -911,13 +894,13 @@ } }, "node_modules/@aws-sdk/middleware-websocket": { - "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==", + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.51.tgz", + "integrity": "sha512-jdgP3jR5Q96j1jjZ98GGwpGg1CBNFIO2YE+vXg8cg8PvNY4NvgQNYJsqDaRX2PYv5gSUX/+C0D58Fhspj9ELMQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/signature-v4": "^5.6.12", @@ -929,14 +912,14 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "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==", + "version": "3.997.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.43.tgz", + "integrity": "sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/signature-v4-multi-region": "^3.996.44", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.45", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", @@ -948,12 +931,12 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "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==", + "version": "3.996.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.45.tgz", + "integrity": "sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/types": "^3.974.4", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -963,14 +946,14 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1108.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", - "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "version": "3.1111.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1111.0.tgz", + "integrity": "sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.7", - "@aws-sdk/nested-clients": "^3.997.42", - "@aws-sdk/types": "^3.974.3", + "@aws-sdk/core": "^3.977.8", + "@aws-sdk/nested-clients": "^3.997.43", + "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" @@ -980,9 +963,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", - "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "version": "3.974.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.4.tgz", + "integrity": "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -993,9 +976,9 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", - "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.39.tgz", + "integrity": "sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.16.1", @@ -1015,13 +998,13 @@ } }, "node_modules/@axe-core/playwright": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", - "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", "dev": true, "license": "MPL-2.0", "dependencies": { - "axe-core": "~4.12.1" + "axe-core": "~4.13.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" @@ -1551,16 +1534,50 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", - "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", + "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^8.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-compilation-targets": { @@ -1581,25 +1598,171 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", - "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-8.0.1.tgz", + "integrity": "sha512-++t3ZktzlLmASAxIlxeXQK9Z2YwUafYGYcvGBFevqOqt16HozVHStUoQvWD09fzAZOb/uJGpUTBuGK41AJAuOA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/helper-replace-supers": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/traverse": "^7.29.7", - "semver": "^6.3.1" + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-member-expression-to-functions": "^8.0.0", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/helper-replace-supers": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/traverse": "^8.0.0", + "semver": "^7.7.3" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/@babel/helper-globals": { @@ -1612,19 +1775,152 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", - "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-8.0.0.tgz", + "integrity": "sha512-xkXrMbtk87Gk7+oKBVmBc6EORg/Qwx++AHESldmHkpvG8wgccdhJJFwrzqlF382Fk8wfXhJHWE/g/43QvEGNPQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-module-imports": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", @@ -1657,60 +1953,363 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", - "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-8.0.0.tgz", + "integrity": "sha512-3W6satvtPuCUkUx63S2jMoW9EQNYkADgs1HTfufmL7gCmAulHMKupA/12WNz4A0GMMFn/YnWWwqOT9IZrJHQjg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^8.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-8.0.1.tgz", + "integrity": "sha512-3PKFgjTyPlhFhorfP+SjKQxLViIL++zWjFOO4hGriYU+Bsm983DxEM1JmDRJVWXV0O9npu+xXRqz7Pbd3mh70g==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", - "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-8.0.1.tgz", + "integrity": "sha512-B1SZADIcy3tmH8CmWvj4SHi/oAPom4UL3uknTc2QRNsPVLFk/sPnZvQL/8kj7Y5omvjMqie0vklvs6XM4OLW5Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.29.7", - "@babel/helper-optimise-call-expression": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/helper-member-expression-to-functions": "^8.0.0", + "@babel/helper-optimise-call-expression": "^8.0.0", + "@babel/traverse": "^8.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^8.0.0" } }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", - "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "node_modules/@babel/helper-replace-supers/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-replace-supers/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-8.0.0.tgz", + "integrity": "sha512-xmCA9kP3IhySsqhzwIdWGlDN/1A4cCKNBO/uwZx/3YzmDoMePwno2Q5/Bq0q+tYaKbeF940YiKV/kaW8Mzvpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -1769,160 +2368,580 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", - "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-8.0.2.tgz", + "integrity": "sha512-+C6O6KKXU7BBq1GNaIkFJxrALUVGRcr+WeWm4OcuRl3h+l/CmNfcTLMrT2Lm3uvGBimBH/8pEBRrXJFLoO67Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/plugin-syntax-decorators": "^7.29.7" + "@babel/helper-create-class-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-syntax-decorators": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", - "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-8.0.1.tgz", + "integrity": "sha512-NI+0S/6MvR6GlcQFwjDZ+WIc2qvG6TXN534lYs9llNldwW4b7Dh6KTtk030FA0xWdYGs4t1lWo+OEWN8wGB+Nw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" + "@babel/helper-plugin-utils": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-8.0.1.tgz", + "integrity": "sha512-n0jtCOxEovhU7METqSQjcZO9pX53nu9uNIjMS+hEt+Nt9jA7oOZoBIgbCxhhASmF6T6rPDGge5UAvh6Z4eFz/g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" + "@babel/helper-plugin-utils": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", - "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-8.0.3.tgz", + "integrity": "sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" + "@babel/helper-plugin-utils": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", - "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-8.0.1.tgz", + "integrity": "sha512-RtR8uLDl0QcCmqMNIkM8gmDeYZ3rS0ZH+sa+I6sfc09yFoqfp9AEPgBstq9KyfVb0lFCVSRFfJXCI70FIl5ccw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/helper-plugin-utils": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", - "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-8.0.1.tgz", + "integrity": "sha512-VzDIYwBlLCpV6mJfloRdJm8HmYnMqs7O+bGha8yfg2kP7jAdxeCw6yZBVBeaKKQUThtSU52iy+3lB7DhYsbOBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/plugin-transform-destructuring": "^7.29.7" + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-transform-destructuring": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", - "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-8.0.1.tgz", + "integrity": "sha512-PMuzulWrrzFNmY3lXSk/tV9NRb7y0eZZLJY4UEo2TKszroxvUZHAPPi+T9FDyrQhod+TQA+t+8/QYaaMpiEuhA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7" + "@babel/helper-module-transforms": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/helper-module-imports": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-8.0.0.tgz", + "integrity": "sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/helper-module-transforms": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-8.0.1.tgz", + "integrity": "sha512-UgAhl1kqiW5ciE0yCXqqvnb4H2n3IELJ7lIIQRezwDPilPEZX5i+Rvbja9MFTkwUn2biEiSMeV31aUzR4Lwakw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.0", + "@babel/traverse": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-8.0.1.tgz", + "integrity": "sha512-soLishXlkyu6jcICPyO3HEP7A3GCzKEnn7XfvYrImuWEOwFAz93qShmWSYPf5ww0ZkO4By0zsN2bVIDF54fSdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-8.0.1.tgz", + "integrity": "sha512-NgkoF7Uq+30TmOPDdNUimT0Nta02uVjqJRFNlVWKrbOCu/CkzfHa4aMnIs0lMpkMmZmWA1e42Va+F04i/pY1zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-module-imports": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/plugin-syntax-jsx": "^8.0.1", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-8.0.1.tgz", + "integrity": "sha512-Hb+HUZpV9KFHjm+F+P3aLDMi8QXU9l3ROCQv20z18Me2sGyW5nNNR5YTevNlgHvCpFek3BnAwhDGq/BRndXViw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/helper-module-imports": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-8.0.0.tgz", + "integrity": "sha512-NZ7mSS93o4ndX4KrbD7W8Sf3QT8Qe24PrnFyUcuOPDzK6faqDFKjY9RG7he7+I7FdiQ4llpnosFqzrXa+Vy3Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-8.0.1.tgz", + "integrity": "sha512-7/8UwU8hoPBurXa9tUiTTC8aACTRy5tCqLUtqikHp2eGiWoEB57AduOdbQ71OOMTEvawKrGhv3WfzkDpI+/oSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-plugin-utils": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", - "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-8.0.1.tgz", + "integrity": "sha512-0Svqp3413Eg0GElldykF/T7SNsxQO5YVGD70fZyAdZTnX8WRgcopmbiU7GTa5xY5ZnJcEpNbfns8/GjX+/1yeA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-create-class-features-plugin": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/plugin-syntax-typescript": "^7.29.7" + "@babel/helper-annotate-as-pure": "^8.0.0", + "@babel/helper-create-class-features-plugin": "^8.0.1", + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-skip-transparent-expression-wrappers": "^8.0.0", + "@babel/plugin-syntax-typescript": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-8.0.1.tgz", + "integrity": "sha512-jrFuPp/pTddFZbtmWhdLNAYc6UMcpboeUPnw0BBrm4nOmcAko/1TRcFi1PzWCeOFRU+VaSiKmat87W1HvR7mIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-transform-react-display-name": "^8.0.1", + "@babel/plugin-transform-react-jsx": "^8.0.1", + "@babel/plugin-transform-react-jsx-development": "^8.0.1", + "@babel/plugin-transform-react-pure-annotations": "^8.0.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "peerDependencies": { + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-react/node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-8.0.1.tgz", + "integrity": "sha512-qrPhQIN1NLrPmzgazF9XKQqXrOcp/WJly+K+6ReFonn24FZqRJO7clxOJo6Ni75L+2vAqI3cHVU2OJLBxoPp5A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" + "@babel/helper-plugin-utils": "^8.0.1", + "@babel/helper-validator-option": "^8.0.0", + "@babel/plugin-transform-modules-commonjs": "^8.0.1", + "@babel/plugin-transform-typescript": "^8.0.1" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^8.0.0" + } + }, + "node_modules/@babel/preset-typescript/node_modules/@babel/helper-validator-option": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/runtime": { @@ -2256,9 +3275,9 @@ } }, "node_modules/@cyclonedx/cyclonedx-npm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-npm/-/cyclonedx-npm-6.0.0.tgz", - "integrity": "sha512-kpWjjV0j5y0mMHUB5dSx1hxweH8K2blSqkgdQ6eHgU7aClB4CcXGhbHtGY6WVHSo3A01Rt7WOLas/wQ1E+tBDg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-npm/-/cyclonedx-npm-6.0.1.tgz", + "integrity": "sha512-/aU3bBC6qP6cV/qQ5SfUSygE/+2hQhwgg6sJML31/gZ96NyMvIUuwdk637H4z+LS/NryRT2kjR2wtD0qBEVVHQ==", "dev": true, "funding": [ { @@ -3321,6 +4340,12 @@ } } }, + "node_modules/@fumari/image-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@fumari/image-size/-/image-size-0.1.0.tgz", + "integrity": "sha512-x2o9u6P8uKUK15B8XgEoRhR3PgLoLSbQKK6FUCd14JzumEw+e8FXZPelij/dZ4VQVMp4r61VD3DcvSo3aEhLAA==", + "license": "MIT" + }, "node_modules/@gar/promise-retry": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", @@ -4659,20 +5684,20 @@ } }, "node_modules/@jscpd/finder": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@jscpd/finder/-/finder-4.2.5.tgz", - "integrity": "sha512-Rw0dtwp/EeLANbujOubuQeJIuXXXkAlT+f5geZhwkB9TxEYP0hqNrdOJUK/TDBKQjRGrOizEtdNy+S4UlbdzOQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@jscpd/finder/-/finder-4.3.0.tgz", + "integrity": "sha512-MnEUyier0D6P9zRIhAlBoyJUV8BYT6d5FDZuhR7X23FdZAgdMV8yaRRO1Q1EveK9/ReA6WVHT1HE8F15rij71A==", "dev": true, "license": "MIT", "dependencies": { "@jscpd/core": "4.2.5", - "@jscpd/tokenizer": "4.2.5", + "@jscpd/tokenizer": "4.2.6", "blamer": "^1.0.6", "bytes": "^3.1.2", "cli-table3": "^0.6.5", "colors": "^1.4.0", "fast-glob": "^3.3.2", - "fs-extra": "^11.2.0", + "fs-extra": "^11.3.6", "markdown-table": "^2.0.0", "pug": "^3.0.4" } @@ -4734,9 +5759,9 @@ } }, "node_modules/@jscpd/tokenizer": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.2.5.tgz", - "integrity": "sha512-UM8Wx/jwahmflqQExlcKMQTYOAy58N/fn7Pv6NYrkD3EZm/FTk7gW97wkXy5aDE1Ts9oBUpT9tLY2rz7ogCHAQ==", + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@jscpd/tokenizer/-/tokenizer-4.2.6.tgz", + "integrity": "sha512-/eyFjINWLs2mrBTU4H681bs855r5oRyl1O3mZxcd7TpL1JIG85a7pps0RkRPAe9c/2KcjVCc82HbhOz86M0n5g==", "dev": true, "license": "MIT", "dependencies": { @@ -4985,9 +6010,9 @@ ] }, "node_modules/@lobehub/icons": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/@lobehub/icons/-/icons-5.15.0.tgz", - "integrity": "sha512-+Zca8eBEeogivK9cyOh37TUYCJiISo2EisKNElIFP+mS9P5dUX2e9HxEs9V4h2Z446VXyC/Gp2i86mI/pjJlxg==", + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/@lobehub/icons/-/icons-5.16.0.tgz", + "integrity": "sha512-EYiHGyo7FZ4VPvsDx6Q8STN+erAwyeAFgwDaSgn547FrBsZ6NNFwUTwvgim7jruGErLJEKpd1IFgw4mD8DaW/A==", "license": "MIT", "workspaces": [ "packages/*" @@ -5375,15 +6400,15 @@ "license": "MIT" }, "node_modules/@next/env": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", - "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.1.tgz", + "integrity": "sha512-35G3xwkQUb2oETSDjFXGrVugknoayLFBh7vSE+yAcl9IP2zT9wyGwq7297AYHR11kJld807t5f8AJBs6WBzXsQ==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.0.tgz", - "integrity": "sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.1.tgz", + "integrity": "sha512-B4SznlXwVpaLDa7Tbi6zLuueria2d/PmFDhXyDymPGrk2r1n/RMJmcn5FZq1L64k+Jsyte1lKvOr7lP9Xo80mQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5392,9 +6417,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", - "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.1.tgz", + "integrity": "sha512-ABMIu2zQ7cnNIHm5ivKGwZwUrm0pAai3yiJ/gK/rF1c1VP9UOnj7XECbMKFdVKp9I9eMYq9NoDs1WXOoowxzJw==", "cpu": [ "arm64" ], @@ -5408,9 +6433,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "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==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.1.tgz", + "integrity": "sha512-gNG21e/UnrroeScbY/QndUEdl0mF1FRibW7BBeYUz/5ABCepjqDdEdgr592vpzMtCn/m7FTjYq3TN4TpyDnutw==", "cpu": [ "x64" ], @@ -5424,9 +6449,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "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==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.1.tgz", + "integrity": "sha512-6B6Lw016iwNUQuaJoraMMTLh6TwHzFUtxipSScD1F3YyymcrRWkobodRS2ftIOkF5vrs4zNlyUrTC5YZQ9Lz5w==", "cpu": [ "arm64" ], @@ -5443,9 +6468,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "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==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.1.tgz", + "integrity": "sha512-JUiPXZKK9wOhjf4MgDiH29GZLxfqOesbLtHq2pDxwH/WwscTRV2ToymnOTh1egzaZf0ueUf8T2+CeYTGHjW0Iw==", "cpu": [ "arm64" ], @@ -5462,9 +6487,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "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==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.1.tgz", + "integrity": "sha512-Uog9jsrmIRIL/lfvIp9htmskSNC7JcQsMVucXL2V2YY1y/D9IUN3LPEafqy0zRJ2cIU1SQ0V6F6TlffQ+pLAGg==", "cpu": [ "x64" ], @@ -5481,9 +6506,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "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==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.1.tgz", + "integrity": "sha512-6yy3FT13KgUFOj5H8bl8w/6nKiJwHIvbtwh1V+1acsu+7y4tJjnemSa6mhsh53BeoVrlozE+fMgZhXH46WmjMA==", "cpu": [ "x64" ], @@ -5500,9 +6525,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "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==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.1.tgz", + "integrity": "sha512-iOoN1QecUoGNZik536U/vtK43YwgyrCsGIkth52yIkl612n+0C9MjSnJbQAikISpb+WYRooBVhaDlUW7iZoKog==", "cpu": [ "arm64" ], @@ -5516,9 +6541,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "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==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.1.tgz", + "integrity": "sha512-d/k+PpAriUPaeMJJOG7HUSdqfEX46FEPWU1p3/nm2ACmXhj9hFEWdFODUBIpkuijXYkfL90qZzTqVPRp4BW/hw==", "cpu": [ "x64" ], @@ -7245,9 +8270,9 @@ ] }, "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz", - "integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.143.0.tgz", + "integrity": "sha512-n9uozULWflPqBtdmI8lAabLqGKNgLVNN0ZH8HfgCwpKGNtzRzauB76jTiW/3YLkcA7N1zskpi9GdVnZuu1SAvg==", "cpu": [ "arm" ], @@ -7262,9 +8287,9 @@ } }, "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.142.0.tgz", - "integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.143.0.tgz", + "integrity": "sha512-9BbdjHETk6O3zH/DDid9IgBtF0GlpLabNKN231uraXpRDSfY+iiZxTP5bk1Z63GBownVdhdINFIeddmMz4MzpQ==", "cpu": [ "arm64" ], @@ -7279,9 +8304,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.142.0.tgz", - "integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.143.0.tgz", + "integrity": "sha512-gh+6ecoHUy4/sUcolBl/1qPXKBbYNxFY0Pk0ujgQvINTMSftJY7o4yb8gOkDJPeZeB8+a+u7xTe6umoP8N5HFA==", "cpu": [ "arm64" ], @@ -7296,9 +8321,9 @@ } }, "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.142.0.tgz", - "integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.143.0.tgz", + "integrity": "sha512-qd1hl2d+lXgHv/VQ/M9qm8TrMC5T4RqDBwtOnl+1D0QMjwcz+8AaB4JSg8STgeag0GP6a6L74XEGAsrTSJWNzQ==", "cpu": [ "x64" ], @@ -7313,9 +8338,9 @@ } }, "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.142.0.tgz", - "integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.143.0.tgz", + "integrity": "sha512-M5XXcNa7aOqLPKTR41msfghKu2yQ4xWvCm11/gwU0JzOzHNk5sgW//rVEjJ+LO48+VDAMzXTSzurUVxIDKwozw==", "cpu": [ "x64" ], @@ -7330,9 +8355,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.142.0.tgz", - "integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.143.0.tgz", + "integrity": "sha512-T/GXusuOkPNQhCQCSBbcU/N8j0rAypuDBl1IyFK+lyYT594XsVz80clPC/OtbSSpBGyJxj8uYEfctxVuxVYoww==", "cpu": [ "arm" ], @@ -7347,9 +8372,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.142.0.tgz", - "integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.143.0.tgz", + "integrity": "sha512-oKu4RcBlXSqo3OC62dp6YTnQaZIurNDpCX3BnAM3+bJxt7s8J2TJKMnC0UYer1qhlRaDCg6wkTaTw+2IlsZ12w==", "cpu": [ "arm" ], @@ -7364,9 +8389,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.142.0.tgz", - "integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.143.0.tgz", + "integrity": "sha512-WJBbD186AZmMGaSIhlktC+rPl8L3peCTXAh88Ih9uEvK0en2mPojGyCGYiL6mHtV1RPV3JyfJW5t6n5hh0lXhA==", "cpu": [ "arm64" ], @@ -7384,9 +8409,9 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.142.0.tgz", - "integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.143.0.tgz", + "integrity": "sha512-t1AcYOwEzgceadT4v5e+vaCCb0AncCA3v5AyzfBAz/tMq11qzVccXKzNHtkWdjBsgvTKwRkaUF3QvT4kot8vcQ==", "cpu": [ "arm64" ], @@ -7404,9 +8429,9 @@ } }, "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.142.0.tgz", - "integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.143.0.tgz", + "integrity": "sha512-RsnO/NoD8376LMJq8JS8TwI0ieNaFRTuNe2GVJntQg6gwZNMENZsEbknHdVwjpOmxdGLGodcwaGSbAeRr5Bgjw==", "cpu": [ "ppc64" ], @@ -7424,9 +8449,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.142.0.tgz", - "integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.143.0.tgz", + "integrity": "sha512-48fSVfR9TZi5CASZFyv0VC6z6BCoeihFsX031mAD/oSH7d9PYsPgIqza7d9mjP7Z2KTEpTFyH6SIu0Ui6R1vdg==", "cpu": [ "riscv64" ], @@ -7444,9 +8469,9 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.142.0.tgz", - "integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.143.0.tgz", + "integrity": "sha512-T8CpdD+SfE01DnIOD4HpVxu0ZJOfMJ/VhCvikKfaXAxkZ+9veyLM/D2hpi7Y2hFUyPmVQO3FNZHmYzV/WlVR4g==", "cpu": [ "riscv64" ], @@ -7464,9 +8489,9 @@ } }, "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.142.0.tgz", - "integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.143.0.tgz", + "integrity": "sha512-QLdeMsCcacenPEFsfxnBUDF1y6opyz5+fmOz9bfD5Y7fiGCMupUCuB3KTPQhNwshIG1P9fPqar9MHxuBDd4bwQ==", "cpu": [ "s390x" ], @@ -7484,9 +8509,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.142.0.tgz", - "integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.143.0.tgz", + "integrity": "sha512-659ujfqLy6k7cuH3sbzhd8b+ztSq+i6E2E9pG78Q0BmHjAExfGIdgc8cGgMdwAozDXeZFHkJ+LXYJdWsaGdgyw==", "cpu": [ "x64" ], @@ -7504,9 +8529,9 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.142.0.tgz", - "integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.143.0.tgz", + "integrity": "sha512-/Mw/9j4TfZcnKphPrzOE6t4MMknXadcAAuVUlDRTF/ETWB5xOgQvOJV2Mh9We/bWxZdoxaGAdc+hy4GuYwQ2yQ==", "cpu": [ "x64" ], @@ -7524,9 +8549,9 @@ } }, "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.142.0.tgz", - "integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.143.0.tgz", + "integrity": "sha512-8rIKWR2BFuifbIK/1XB9wTaSdtuJ25dlE7ZQYDnEwj/2xH2vHsxnvIjHT3ZjSVuLLwGGlSslIG/fbOJ8TV8rTw==", "cpu": [ "arm64" ], @@ -7540,96 +8565,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.142.0.tgz", - "integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", - "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.142.0.tgz", - "integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.143.0.tgz", + "integrity": "sha512-5U9kQYMfRRI6Zq7KDxgbIP0RMnKrfn3gLepRMgJuRkPSUALTiRCk9d/uyhb4lGDjUdzwK7mBkKqhLgzBPCmLpQ==", "cpu": [ "arm64" ], @@ -7644,9 +8583,9 @@ } }, "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.142.0.tgz", - "integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.143.0.tgz", + "integrity": "sha512-25P7AaHk4R88Yv2XH4gToDVmh0cOu+bEURQU10CRrmvgabfRArSGAP5osmwUKeSUHj0VS50upbpbRWWW/m7mHA==", "cpu": [ "ia32" ], @@ -7661,9 +8600,9 @@ } }, "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.142.0.tgz", - "integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.143.0.tgz", + "integrity": "sha512-ORMh3JE1s6V7ySicdRK7vgaDQnn5o+UHg9ct989PlWHbel8O9ARrmWXM6kZjrBMtNucxNayQ8g69G0VfWzhANw==", "cpu": [ "x64" ], @@ -10244,33 +11183,33 @@ "license": "MIT" }, "node_modules/@stryker-mutator/api": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-9.6.1.tgz", - "integrity": "sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-10.0.0.tgz", + "integrity": "sha512-ZtAJ0ZT3MVRCWJTBE2h90XB/6E+4lifHYtcTyNG6nU2nLekPgTo4gD5esjX6Okxo1b/JB4jJzyxYB54fwKAoJw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "mutation-testing-metrics": "3.7.3", - "mutation-testing-report-schema": "3.7.3", + "mutation-testing-metrics": "3.8.4", + "mutation-testing-report-schema": "3.8.4", "tslib": "~2.8.0", "typed-inject": "~5.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, "node_modules/@stryker-mutator/core": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-9.6.1.tgz", - "integrity": "sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-10.0.0.tgz", + "integrity": "sha512-ZvMsRyaXQQ5e6Thcid9pkuODv6Fn9E3nrBQJUap+hcJuGJ4unm26afo3m6YKSjn8kinyxJ/3TXf0cTWRDaTxVw==", "dev": true, "license": "Apache-2.0", "dependencies": { "@inquirer/prompts": "^8.0.0", - "@stryker-mutator/api": "9.6.1", - "@stryker-mutator/instrumenter": "9.6.1", - "@stryker-mutator/util": "9.6.1", - "ajv": "~8.18.0", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/instrumenter": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "ajv": "~8.20.0", "chalk": "~5.6.0", "commander": "~14.0.0", "diff-match-patch": "1.0.5", @@ -10280,9 +11219,9 @@ "lodash.groupby": "~4.6.0", "minimatch": "~10.2.4", "mutation-server-protocol": "~0.4.0", - "mutation-testing-elements": "3.7.3", - "mutation-testing-metrics": "3.7.3", - "mutation-testing-report-schema": "3.7.3", + "mutation-testing-elements": "3.8.4", + "mutation-testing-metrics": "3.8.4", + "mutation-testing-report-schema": "3.8.4", "npm-run-path": "~6.0.0", "progress": "~2.0.3", "rxjs": "~7.8.1", @@ -10297,7 +11236,24 @@ "stryker": "bin/stryker.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" + } + }, + "node_modules/@stryker-mutator/core/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, "node_modules/@stryker-mutator/core/node_modules/chalk": { @@ -10463,33 +11419,168 @@ } }, "node_modules/@stryker-mutator/instrumenter": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-9.6.1.tgz", - "integrity": "sha512-5K8wH4Pthly25c2uKKik4Dfcoeou7sbJdFS6u3QIYHlulgFVDJwtEMWTZGkZfs7IiUEXIDNa0keRACq5jn5AvA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-10.0.0.tgz", + "integrity": "sha512-B7Wmn1KlEWyFeOz6D6oGvQGRfi5Xw3VemG6dEKvFQp4qLvxD9Mf4kcZghfxffgnYwXd3bFgqXsJ+ZGlhdfIOrQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@babel/core": "~7.29.0", - "@babel/generator": "~7.29.0", - "@babel/parser": "~7.29.0", - "@babel/plugin-proposal-decorators": "~7.29.0", - "@babel/plugin-transform-explicit-resource-management": "^7.28.0", - "@babel/preset-typescript": "~7.28.0", - "@stryker-mutator/api": "9.6.1", - "@stryker-mutator/util": "9.6.1", - "angular-html-parser": "~10.4.0", - "semver": "~7.7.0", + "@babel/core": "~8.0.0", + "@babel/generator": "~8.0.0", + "@babel/parser": "~8.0.0", + "@babel/plugin-proposal-decorators": "~8.0.0", + "@babel/plugin-transform-explicit-resource-management": "^8.0.0", + "@babel/preset-react": "~8.0.0", + "@babel/preset-typescript": "~8.0.0", + "@babel/traverse": "~8.0.4", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/util": "10.0.0", + "angular-html-parser": "~10.11.0", + "semver": "~7.8.0", "tslib": "2.8.1", - "weapon-regex": "~1.3.2" + "weapon-regex": "~2.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" } }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/code-frame": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", + "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.0", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/helper-globals": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", + "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/template": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/traverse": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", + "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-globals": "^8.0.0", + "@babel/parser": "^8.0.4", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.4", + "obug": "^2.1.1" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@stryker-mutator/instrumenter/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -10500,29 +11591,29 @@ } }, "node_modules/@stryker-mutator/tap-runner": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@stryker-mutator/tap-runner/-/tap-runner-9.6.1.tgz", - "integrity": "sha512-b5ryfiRQHH5VoWP++VEA9KYiU6lhVbE9znooFaWRr7umaAtqKmlWrFinKA6fghwiFpdxerRpctLDT8uVKzeQEw==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/tap-runner/-/tap-runner-10.0.0.tgz", + "integrity": "sha512-tVCu5g50KRZ7eZaZlQWUXEfNHaO/79L/eyXy939GEL/Sv8n+NsFEaU8yFmscXTTe6bF3qJM85Y4XsrmM2MEOuQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@stryker-mutator/api": "9.6.1", - "@stryker-mutator/util": "9.6.1", + "@stryker-mutator/api": "10.0.0", + "@stryker-mutator/util": "10.0.0", "glob": "~13.0.0", "tap-parser": "~17.0.0", "tslib": "~2.8.0" }, "engines": { - "node": ">=14.18.0" + "node": ">=22.0.0" }, "peerDependencies": { - "@stryker-mutator/core": "9.6.1" + "@stryker-mutator/core": "10.0.0" } }, "node_modules/@stryker-mutator/util": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-9.6.1.tgz", - "integrity": "sha512-Lk/ALVctJjFv1vvwR+CFoKzDCWvsBlq7flDUnmnpuwTrGbm156EdZD1Jjq4o8KdOap0ezUZqQNE9OAI1m2+pUQ==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-10.0.0.tgz", + "integrity": "sha512-LzOpHiJaCp2ABQgnPMlrQQcsK43bd5Vo/2FGL78aN62yDoeRQ+4j3tzeuXxK5OAHdC3fUz6TDoy4IsoAuLAd3w==", "dev": true, "license": "Apache-2.0" }, @@ -11386,9 +12477,9 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.0.tgz", - "integrity": "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", "dependencies": { @@ -11405,7 +12496,13 @@ "yarn": ">=1" }, "peerDependencies": { - "@testing-library/dom": ">=10 <11" + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -11894,6 +12991,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -12120,17 +13224,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", - "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/type-utils": "8.66.0", - "@typescript-eslint/utils": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -12143,7 +13247,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.66.0", + "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -12159,16 +13263,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", - "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "engines": { @@ -12184,14 +13288,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", - "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.66.0", - "@typescript-eslint/types": "^8.66.0", + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "engines": { @@ -12206,14 +13310,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", - "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12224,9 +13328,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", - "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "dev": true, "license": "MIT", "engines": { @@ -12241,15 +13345,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", - "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -12266,9 +13370,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", - "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { @@ -12280,16 +13384,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", - "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.66.0", - "@typescript-eslint/tsconfig-utils": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/visitor-keys": "8.66.0", + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -12337,16 +13441,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", - "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.66.0", - "@typescript-eslint/types": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0" + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12361,13 +13465,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", - "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -12892,12 +13996,12 @@ } }, "node_modules/@xyflow/react": { - "version": "12.11.2", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", - "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "version": "12.11.3", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.3.tgz", + "integrity": "sha512-G3jogHz2GWUtIOkhavUGno2YzY9u6fILIJBttfsBendb0/HWB90JG+sOTAvlIMEwyvq9zgy9V9ZQSwyQjR5QzQ==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.79", + "@xyflow/system": "0.0.80", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -12945,9 +14049,9 @@ } }, "node_modules/@xyflow/system": { - "version": "0.0.79", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", - "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "version": "0.0.80", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.80.tgz", + "integrity": "sha512-ywc3ZqG91brzWrH1WlwMdIX4goOfrpBy6AbLdVSaof/Xx9l138ijIKRExM6EkMro2F+OImGmSiA/WKcXvKVcfA==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", @@ -13368,9 +14472,9 @@ } }, "node_modules/angular-html-parser": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.4.0.tgz", - "integrity": "sha512-++nLNyZwRfHqFh7akH5Gw/JYizoFlMRz0KRigfwfsLqV8ZqlcVRb1LkPEWdYvEKDnbktknM2J4BXaYUGrQZPww==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-10.11.0.tgz", + "integrity": "sha512-3vERzJ65UFDr3C7uozLJwsNcQS3FS784dSh583oDgDTTZMgXe3/pdyXgKndxiP5R2lvYRfW6gSl145Hnyf2OFA==", "dev": true, "license": "MIT", "engines": { @@ -13874,9 +14978,9 @@ "license": "MIT" }, "node_modules/axe-core": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", - "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", "dev": true, "license": "MPL-2.0", "engines": { @@ -16168,9 +17272,9 @@ } }, "node_modules/cron-parser": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.8.1.tgz", - "integrity": "sha512-fVw5nGEkTVmiPKo3fY0j28Thq6jR00VKWyL22llWrsbII4sDHI+8Kx1kcL+QzGQJfCfk64bbMotrgTZRpzYpLQ==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.10.0.tgz", + "integrity": "sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==", "license": "MIT", "dependencies": { "luxon": "^3.7.2" @@ -18376,13 +19480,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.0.tgz", - "integrity": "sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.1.tgz", + "integrity": "sha512-0vtrpwFVHFEkycUgV/DyrG29OS+HSRdah5Yu8YuZoiBMtlAT6NIiWzaLwDkJZxr2kGfx+9LIvfQ7KHAlEs0VsA==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.3.0", + "@next/eslint-plugin-next": "16.3.1", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -19799,24 +20903,20 @@ } }, "node_modules/framer-motion": { - "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==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.1.0.tgz", + "integrity": "sha512-QSZrF0Id3QGuHJ+OL+9PSY9pk86C8ERFalwAGSchzTm65+ZoGH/RM26lmEARLljcHj2lqhv0jZOOks+EI3COOw==", "license": "MIT", "dependencies": { - "motion-dom": "^12.43.0", - "motion-utils": "^12.39.0", + "motion-dom": "^13.0.0", + "motion-utils": "^13.0.0", "tslib": "^2.4.0" }, "peerDependencies": { - "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, "react": { "optional": true }, @@ -19842,9 +20942,9 @@ "optional": true }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "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", "dependencies": { @@ -19884,11 +20984,12 @@ } }, "node_modules/fumadocs-core": { - "version": "16.14.3", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.14.3.tgz", - "integrity": "sha512-xoGy6YelmU8GD4RKUiSuraFnRW91DqBM328Gs/YasltLrnMDWgEaYMKAcrGLVrikpqJkFx+etHo8BcClOMNt+A==", + "version": "16.14.4", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.14.4.tgz", + "integrity": "sha512-vD1gVDwYKATW54D3tD/jPcB7GGipJ8qPXa85gCV3HNhC8u8SkbJdvu/QYklo6gTpULy+J/Aj+5vlg96zE39+Yg==", "license": "MIT", "dependencies": { + "@fumari/image-size": "^0.1.0", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", @@ -19900,13 +21001,13 @@ "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", - "shiki": "^4.4.1", + "shiki": "^4.4.3", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "yaml": "^2.9.0", - "zbsearch": "^3.3.4" + "zbsearch": "^4.0.0" }, "peerDependencies": { "@mdx-js/mdx": "*", @@ -19986,9 +21087,9 @@ } }, "node_modules/fumadocs-mdx": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.2.2.tgz", - "integrity": "sha512-cWtGFSDSWOTykuxym3uzfuIkK9oWyNcAeIWgjqfS1QbkrY9nAh9fmoPCFwMcw7alk5oX5l8sxvRsUG+ZTuShNQ==", + "version": "15.2.3", + "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.2.3.tgz", + "integrity": "sha512-zulK4WKXZcnbiipdvWtKcBClZn8/RekIjZu+/MmuMX3lpiXki485ABhJiJqFQmtD8W59WBN+V/4ydr4TvSoLkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -20002,14 +21103,14 @@ "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.5", - "tinyexec": "^1.2.4", + "tinyexec": "^1.3.0", "tinyglobby": "^0.2.17", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "yaml": "^2.9.0", - "yuku-analyzer": "^0.8.1", + "yuku-analyzer": "^0.8.3", "zod": "^4.4.3" }, "bin": { @@ -20072,9 +21173,9 @@ } }, "node_modules/fumadocs-ui": { - "version": "16.14.3", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.14.3.tgz", - "integrity": "sha512-ASL9BgFxSe6VrbQ60nxVfpnKBPboeADp330JQvyEAqR1U8uw0T1+Vko8ySXRAWbivdZfYK5fZbCh78EOAMTEqw==", + "version": "16.14.4", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.14.4.tgz", + "integrity": "sha512-EW3pRRqQ1G1/4RVTsEhCaJTvXGHCRe92hySyIb5fAecJ6MVO2TNymemqqB5mxmXQGxNSOprtEybrB2mR0yJc8g==", "license": "MIT", "dependencies": { "@fuma-translate/react": "^1.0.2", @@ -20091,19 +21192,19 @@ "@radix-ui/react-tabs": "^1.1.21", "class-variance-authority": "^0.7.1", "cnfast": "^0.1.0", - "lucide-react": "^1.28.0", - "motion": "^12.43.0", + "lucide-react": "^1.31.0", + "motion": "^13.1.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.4.1", + "shiki": "^4.4.3", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.14.3", + "fumadocs-core": "16.14.4", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -23807,9 +24908,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz", - "integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", + "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", "funding": [ { "type": "github", @@ -23829,20 +24930,20 @@ } }, "node_modules/jscpd": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-4.2.5.tgz", - "integrity": "sha512-KDpApYw1ChGelfHb7MwYTEx694OnW52pv3McAasidUV4ILcGDQMiVJzB+vI8ox+ZPVfOSvdXQCk8uRa9B0LXnw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-4.3.0.tgz", + "integrity": "sha512-yUqcHy/USHvzFamS6Loo49MCSW0Dc+4RL6ELH+Un9o2/jRSQbs9+a5hjfFceV2d3Zgv1opv5PXJ0eZ3fEQdjkg==", "dev": true, "license": "MIT", "dependencies": { "@jscpd/badge-reporter": "4.2.5", "@jscpd/core": "4.2.5", - "@jscpd/finder": "4.2.5", + "@jscpd/finder": "4.3.0", "@jscpd/html-reporter": "4.2.5", - "@jscpd/tokenizer": "4.2.5", + "@jscpd/tokenizer": "4.2.6", "colors": "^1.4.0", "commander": "^15.0.0", - "fs-extra": "^11.2.0", + "fs-extra": "^11.3.6", "jscpd-sarif-reporter": "4.2.5" }, "bin": { @@ -24504,9 +25605,9 @@ "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/knip": { - "version": "6.32.0", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.32.0.tgz", - "integrity": "sha512-KDX9OmmOFmlvmxTkrx6Z0GHISMut+pXMSKR8eg84bovaxJKx2NdQD4JYCXveSbvieRe107W6vCD2xCpmz0qBYA==", + "version": "6.32.2", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.32.2.tgz", + "integrity": "sha512-WXTXbmocrw7gqm1A1TQvFN0OgJ7hUSU6E1g6SPRIzzHFogUBhXByc7cYeOFVtJ2uODg7DP4VbESYBYnfbtBYsg==", "dev": true, "funding": [ { @@ -24524,13 +25625,13 @@ "formatly": "^0.3.0", "get-tsconfig": "4.14.1", "jiti": "^2.7.0", - "oxc-parser": "^0.142.0", + "oxc-parser": "^0.143.0", "oxc-resolver": "11.24.2", "picomatch": "^4.0.5", "smol-toml": "^1.7.1", "strip-json-comments": "5.0.3", "tinyglobby": "^0.2.17", - "unbash": "^4.0.4", + "unbash": "^4.0.9", "yaml": "^2.9.0", "zod": "^4.4.3" }, @@ -25448,16 +26549,16 @@ } }, "node_modules/lockfile-lint": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/lockfile-lint/-/lockfile-lint-5.0.0.tgz", - "integrity": "sha512-QcVIVITLZAhWYHU2wbNSOMgwc6EN4Y2sy6mjgS5aikYyRzgDIfotXUsCrm38En+3fZpc58Yu7DF9dNeT/goi1A==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/lockfile-lint/-/lockfile-lint-5.0.1.tgz", + "integrity": "sha512-Ukjf5yGBQwfl7L2niV3in7bU5wEww3+4Dkw89JGTzOuq18tzS7jaszl2oO7M6u+jcim080MfX5E4Gokt1KhRHQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "cosmiconfig": "^9.0.0", "debug": "^4.3.4", - "fast-glob": "^3.3.2", "lockfile-lint-api": "^5.9.2", + "tinyglobby": "^0.2.15", "yargs": "^17.7.2" }, "bin": { @@ -25509,36 +26610,6 @@ } } }, - "node_modules/lockfile-lint/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/lockfile-lint/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/lockfile-lint/node_modules/js-yaml": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", @@ -25942,9 +27013,9 @@ } }, "node_modules/material-symbols": { - "version": "0.45.10", - "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.45.10.tgz", - "integrity": "sha512-2A2mgdfZO4es9DFpIOSMAx3d/7erCLQWRm7zGxA3iXt9jV8e2vNLOszbG3kxzYQ6qp5GAWXVl13de8lS2oHVng==", + "version": "0.46.0", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.46.0.tgz", + "integrity": "sha512-YxmTXwOhLOI6EupAwFfxFERbaDe61dG/tveOSy2HecndGKqvJ74WqXrrXLNWpIGDkk6TDpieuQPDS+hA7+z3Ig==", "license": "Apache-2.0" }, "node_modules/math-intrinsics": { @@ -27567,23 +28638,19 @@ "optional": true }, "node_modules/motion": { - "version": "12.43.0", - "resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz", - "integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==", + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-13.1.0.tgz", + "integrity": "sha512-qtvscq59uCPdWnNW4SdSkrxR+BS/QYsa923bx7ocA+4p+ZGNbbVQwkSnG4aukB81QWjtl3AxX36plxNyZLmHCA==", "license": "MIT", "dependencies": { - "framer-motion": "^12.43.0", + "framer-motion": "^13.1.0", "tslib": "^2.4.0" }, "peerDependencies": { - "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, "react": { "optional": true }, @@ -27593,18 +28660,18 @@ } }, "node_modules/motion-dom": { - "version": "12.43.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", - "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.0.0.tgz", + "integrity": "sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==", "license": "MIT", "dependencies": { - "motion-utils": "^12.39.0" + "motion-utils": "^13.0.0" } }, "node_modules/motion-utils": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz", - "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz", + "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", "license": "MIT" }, "node_modules/mpath": { @@ -27809,26 +28876,26 @@ } }, "node_modules/mutation-testing-elements": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.7.3.tgz", - "integrity": "sha512-SMeIPxngJpfjfNYctFpYQQtlBlZaVO0aoB3FKdwrI8Ee/2bkyUuCZzAOCLv1U9fnmfA37dPFq0Owduoxs2XgGQ==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.8.4.tgz", + "integrity": "sha512-5CF1SNa7at5ZH33vEr+21wNebTSrtNIVvnzaUlxortHajOrIPaSLczIWvg6sI/fsExekQ7jookwf2cHftkckqQ==", "dev": true, "license": "Apache-2.0" }, "node_modules/mutation-testing-metrics": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.7.3.tgz", - "integrity": "sha512-B8QrP0ZomErzTPNlhrzKWPNBln+3afwBZPHv0Q7N8wZZTYxMptzb/Gdm3ExXVmioVYrtZAtsDs7W/T/b2AixOQ==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.8.4.tgz", + "integrity": "sha512-DZcmndJBH6nrNs3tpiB3OcMVq9KkG2cHCpJSnDxSxPwi9qrafRmoec40xjhkbzOoX1n7/4UkDqg5tIj4A6nvCw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "mutation-testing-report-schema": "3.7.3" + "mutation-testing-report-schema": "3.8.4" } }, "node_modules/mutation-testing-report-schema": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.7.3.tgz", - "integrity": "sha512-BHm3MYq+ckO+t5CtlG8zpqxc75rdJCkxVlE+fGuGJM3F7tNCQ/OW2N+TQVHN3BHsYa84+BFc6g3AwDYkUsw2MA==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.8.4.tgz", + "integrity": "sha512-s4G71R6Lt/PpZ0cqeglIcgyBdzLM8E+SeCHZAPg1wkSsPtRBa4XfPzAozYKdiJk/TLbNEEb7En9t0/bveuPuxA==", "dev": true, "license": "Apache-2.0" }, @@ -28012,13 +29079,13 @@ } }, "node_modules/next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", - "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.1.tgz", + "integrity": "sha512-hsAp0i7Rh+/dhe7DGIeN2YlpLM1DP4MNxti9EtDMtqcO612X81MvvEj388/oTce9U1EcEIOWDlGq0zRwrBKvuA==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0", - "@swc/helpers": "0.5.15", + "@next/env": "16.3.1", + "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", @@ -28031,14 +29098,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@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", + "@next/swc-darwin-arm64": "16.3.1", + "@next/swc-darwin-x64": "16.3.1", + "@next/swc-linux-arm64-gnu": "16.3.1", + "@next/swc-linux-arm64-musl": "16.3.1", + "@next/swc-linux-x64-gnu": "16.3.1", + "@next/swc-linux-x64-musl": "16.3.1", + "@next/swc-win32-arm64-msvc": "16.3.1", + "@next/swc-win32-x64-msvc": "16.3.1", "sharp": "^0.35.3" }, "peerDependencies": { @@ -28111,15 +29178,6 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/next/node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/node-abi": { "version": "3.89.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", @@ -29017,17 +30075,17 @@ "license": "MIT" }, "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.1.tgz", + "integrity": "sha512-NzwMUB6C1D0+Kd+9iMS/H4k+Ck3cTX6Ckyfr/gAGlmvSE1LUQZnEZvWBi4PYmMwH/S5SMeTXnE+9uAz8uF+pWw==", "license": "MIT", "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" + "powershell-utils": "^0.2.0", + "wsl-utils": "^1.0.0" }, "engines": { "node": ">=20" @@ -29068,9 +30126,9 @@ } }, "node_modules/opencode-ai": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.15.tgz", - "integrity": "sha512-97T8+zrW9LwA1X2giwNSUAmsZm6Ilejq3cLpih3y2X6qYZyT8HjuVawFyZut8F3XBge1pfG7SOh5/1dJcteSOw==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.18.tgz", + "integrity": "sha512-J+5HFq8tf+wPBBpBpMPSNjSytF2/EkNWYfFZh4si1d9auFbQriqDyqZv+vFUsLWERfdMU32Eajwuiq3rKBvZLQ==", "cpu": [ "arm64", "x64" @@ -29087,24 +30145,24 @@ "opencode": "bin/opencode.exe" }, "optionalDependencies": { - "opencode-darwin-arm64": "1.18.15", - "opencode-darwin-x64": "1.18.15", - "opencode-darwin-x64-baseline": "1.18.15", - "opencode-linux-arm64": "1.18.15", - "opencode-linux-arm64-musl": "1.18.15", - "opencode-linux-x64": "1.18.15", - "opencode-linux-x64-baseline": "1.18.15", - "opencode-linux-x64-baseline-musl": "1.18.15", - "opencode-linux-x64-musl": "1.18.15", - "opencode-windows-arm64": "1.18.15", - "opencode-windows-x64": "1.18.15", - "opencode-windows-x64-baseline": "1.18.15" + "opencode-darwin-arm64": "1.18.18", + "opencode-darwin-x64": "1.18.18", + "opencode-darwin-x64-baseline": "1.18.18", + "opencode-linux-arm64": "1.18.18", + "opencode-linux-arm64-musl": "1.18.18", + "opencode-linux-x64": "1.18.18", + "opencode-linux-x64-baseline": "1.18.18", + "opencode-linux-x64-baseline-musl": "1.18.18", + "opencode-linux-x64-musl": "1.18.18", + "opencode-windows-arm64": "1.18.18", + "opencode-windows-x64": "1.18.18", + "opencode-windows-x64-baseline": "1.18.18" } }, "node_modules/opencode-darwin-arm64": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.15.tgz", - "integrity": "sha512-cQNylgrCzhuEJV8EZzMb80RfCJVx972FfD4iNGaGbXxJ/0hAKahhvsasMAK9557o4JMYbsEcxbXGOTuwoDc0sA==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.18.tgz", + "integrity": "sha512-VkG+bz8u8Xqg9NzPK+2/71nEd4DKKlo2NLZurQ1eLAzDnmb1CMYZif/o6Shl8YFuTuYU/30k6yufl4Zr0Ij64g==", "cpu": [ "arm64" ], @@ -29115,9 +30173,9 @@ ] }, "node_modules/opencode-darwin-x64": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.15.tgz", - "integrity": "sha512-Mk+4r5RE+3CmRk7aRW8IUeJJpuUxVn9zeQ7n7sVbUIWbjVGzfwtPWGDkwumqLdxDQiLNRNaPBnESxSaadgSdEA==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.18.tgz", + "integrity": "sha512-xox5XJJ1bI5qDEbxWWR9pWY2Gak5IuSfDcObyWunRUiN7J8OiwyazsJs1HGsTSkVhisk8SNbFIr0/0xQGhxSZg==", "cpu": [ "x64" ], @@ -29128,9 +30186,9 @@ ] }, "node_modules/opencode-darwin-x64-baseline": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.15.tgz", - "integrity": "sha512-1ywad+MK9Fp/vnOnMuIVi+/t2AO6rFz4Q844hgo4//NjVhL187RCsLLChkiOryh8nvovNUQVwbM/l+M8HiSFEg==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.18.tgz", + "integrity": "sha512-NYlIeOOxKPrqY6rdVIjQV4h+eE1AFCHzEcoxXhc8zSvqnCVIO0hMtdNm3l3HhrDucWmdh1BsQBUoZlrBOvAl4w==", "cpu": [ "x64" ], @@ -29141,9 +30199,9 @@ ] }, "node_modules/opencode-linux-arm64": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.15.tgz", - "integrity": "sha512-lJ+pPrJOxo3U2HeXis9aN/vrSFf1iXZXC9S0mTSWtm7qnFOJ3SLI7ALf7NKZoRHOOKUs9RfjR1DMLjzBcGAXog==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.18.tgz", + "integrity": "sha512-e8D3g0qJEIzawEg2+ygW3vkZjAYL2ssyAx4GbihjwXwZFvlZZy5zRWWzdz5KLBoHSTl0FB73vNtnNeXONyHpVQ==", "cpu": [ "arm64" ], @@ -29154,9 +30212,9 @@ ] }, "node_modules/opencode-linux-arm64-musl": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.15.tgz", - "integrity": "sha512-ttDdG8OKLvkfxfApnpuIqltxp5wwqVeYBKF7giE6pyxJQv5v+QsE7cS/CGorwiDrLMKDSrY1ERgFbuGi2MW2wA==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.18.tgz", + "integrity": "sha512-Dp3XByFRRZngPAQotNbOwr22HgZej4r9Ck0Iv/rOVU+oO5fLD9YSpHEwx80FTZOZmFmd/vriCdrR/dGdzabICw==", "cpu": [ "arm64" ], @@ -29170,9 +30228,9 @@ ] }, "node_modules/opencode-linux-x64": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.15.tgz", - "integrity": "sha512-skbLGoflCqUSjSN60lXau6t6F0d1kpcf3N9L76hZsRfKJJeyqv+rybhLK6XUdnBzI1jDgg7+zw9bCvDr+UaI8A==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.18.tgz", + "integrity": "sha512-WmeUnhljYJ252wywKTiW4bNDzsas2njpjPUEh0jM6HKNI4vFxJtREtzaWViY4AKEAcOkLWT8Ll17ixvcHz3AnA==", "cpu": [ "x64" ], @@ -29183,9 +30241,9 @@ ] }, "node_modules/opencode-linux-x64-baseline": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.15.tgz", - "integrity": "sha512-u77OHzEB0MYN3PCW/gzPS6uOdgSVlRinRXXQw0QYNwfEllCUV+EXaEhW2ewChA2LpfYRxrcSP3vgN6jUgFsk/A==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.18.tgz", + "integrity": "sha512-6GvFarhP0pDiXcE9Au8PWoqb9+ZLBIGKrhZxeVAmOJp/gJ8lLL1Eno8O1qpqRb1KjOfOaOD71jHu9xNaqmjEtw==", "cpu": [ "x64" ], @@ -29196,9 +30254,9 @@ ] }, "node_modules/opencode-linux-x64-baseline-musl": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.15.tgz", - "integrity": "sha512-AkpmO8X61X0Jj3WJtcJdIh/qB2J2pJn5hCrPaNuozjCV52+9zdnpbMKnk7IH0fJErWLf2OJfZVeYQhFHpLqHvw==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.18.tgz", + "integrity": "sha512-cXD1gAZ+TTmdE3tnWy5qPstZjrnbPg0rPUwtcHeLaeB+KYxQWEoie1c88oYmZfQ+RWI6+/+LLF0FU29Uyd1Nrw==", "cpu": [ "x64" ], @@ -29212,9 +30270,9 @@ ] }, "node_modules/opencode-linux-x64-musl": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.15.tgz", - "integrity": "sha512-vuctwoTBJoBKb047lBCLqqYY0FUIciM1g9CYeknaKSwA4LuoCXnCfHZzCPfLgcVH1WCCmMMtUhjc8A0V5UZwFA==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.18.tgz", + "integrity": "sha512-lWiWxotqyVTJYiu2dN90KDNDSrwmyhZk8ajzyfAFjwms2TBVx4L0Ly6gkdhz8nxsxwPRv751W8+yUpzetB8AMA==", "cpu": [ "x64" ], @@ -29228,9 +30286,9 @@ ] }, "node_modules/opencode-windows-arm64": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.15.tgz", - "integrity": "sha512-qID37GgDGVsJB+f5HgLNLzk7lclG+rWRgV93owv1xIRADD0IcjHtdyzuU9mPqFgZ5mC0jtyIrnh3ZxQBrlckVg==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.18.tgz", + "integrity": "sha512-Fj1LfP3kXUeD34N0FOw6vebS1oK2YmxA2nviAKOAPo8jLFjEgq5djAJ/WroPmS71VOP1EHlKhRshnK3Sx8pI2w==", "cpu": [ "arm64" ], @@ -29241,9 +30299,9 @@ ] }, "node_modules/opencode-windows-x64": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.15.tgz", - "integrity": "sha512-Tfpj0VyWemoaTQhpiwb2FQ+7BLrvrzBGPWndSVlpOXFJ7bv5I/Rt+Jp/1bf4PYddMDULtSV8dtRg2oL6Sdl29Q==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.18.tgz", + "integrity": "sha512-wbsBZsHDgfHaw9zJogFrrSfWpObtXMFjiA6xl6IFyeVGXsdKi6E8AWIMRZUxKVkf9X0P48mYUuaP1EHgmJCbpA==", "cpu": [ "x64" ], @@ -29254,9 +30312,9 @@ ] }, "node_modules/opencode-windows-x64-baseline": { - "version": "1.18.15", - "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.15.tgz", - "integrity": "sha512-4wr7lg4ajrAvPLrEMht8SZEgTfZBkc6yvFYAlo8Py3ld1EpfPVVu8UeI1Lp2VsaV1QycPj4E0DtoCCmYEI9f0g==", + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.18.tgz", + "integrity": "sha512-IeTrXqbIDbXD5VXpeupRa8aD+l4lSNeq02/K0jnaEPvR5adv42tiN1rOENiTaO2uX+d9cQ/csCaJPBFiQvwoRg==", "cpu": [ "x64" ], @@ -29358,13 +30416,13 @@ } }, "node_modules/oxc-parser": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz", - "integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.143.0.tgz", + "integrity": "sha512-ov0NzaDCOInknS7mP1cwKdJERt3utPW8ldjtdUXQ8Ty0GEFD08wk422vCUN0d7pST6kqtV7dxoI9w1Zi0l/9TA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "^0.142.0" + "@oxc-project/types": "^0.143.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -29373,32 +30431,31 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.142.0", - "@oxc-parser/binding-android-arm64": "0.142.0", - "@oxc-parser/binding-darwin-arm64": "0.142.0", - "@oxc-parser/binding-darwin-x64": "0.142.0", - "@oxc-parser/binding-freebsd-x64": "0.142.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.142.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.142.0", - "@oxc-parser/binding-linux-arm64-musl": "0.142.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.142.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.142.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.142.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.142.0", - "@oxc-parser/binding-linux-x64-gnu": "0.142.0", - "@oxc-parser/binding-linux-x64-musl": "0.142.0", - "@oxc-parser/binding-openharmony-arm64": "0.142.0", - "@oxc-parser/binding-wasm32-wasi": "0.142.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.142.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.142.0", - "@oxc-parser/binding-win32-x64-msvc": "0.142.0" + "@oxc-parser/binding-android-arm-eabi": "0.143.0", + "@oxc-parser/binding-android-arm64": "0.143.0", + "@oxc-parser/binding-darwin-arm64": "0.143.0", + "@oxc-parser/binding-darwin-x64": "0.143.0", + "@oxc-parser/binding-freebsd-x64": "0.143.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.143.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.143.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.143.0", + "@oxc-parser/binding-linux-arm64-musl": "0.143.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.143.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.143.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.143.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.143.0", + "@oxc-parser/binding-linux-x64-gnu": "0.143.0", + "@oxc-parser/binding-linux-x64-musl": "0.143.0", + "@oxc-parser/binding-openharmony-arm64": "0.143.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.143.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.143.0", + "@oxc-parser/binding-win32-x64-msvc": "0.143.0" } }, "node_modules/oxc-parser/node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", "dev": true, "license": "MIT", "funding": { @@ -30482,9 +31539,9 @@ } }, "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", + "integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==", "license": "MIT", "engines": { "node": ">=20" @@ -33368,9 +34425,9 @@ } }, "node_modules/smol-toml": { - "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==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", "license": "BSD-3-Clause", "engines": { "node": ">= 18" @@ -33712,9 +34769,9 @@ "optional": true }, "node_modules/sql.js": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz", - "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==", + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", + "integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==", "license": "MIT" }, "node_modules/sqlite-vec": { @@ -34691,9 +35748,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "license": "MIT", "engines": { "node": ">=18" @@ -35330,16 +36387,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.66.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", - "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.67.0.tgz", + "integrity": "sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.66.0", - "@typescript-eslint/parser": "8.66.0", - "@typescript-eslint/typescript-estree": "8.66.0", - "@typescript-eslint/utils": "8.66.0" + "@typescript-eslint/eslint-plugin": "8.67.0", + "@typescript-eslint/parser": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -36247,9 +37304,9 @@ } }, "node_modules/weapon-regex": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-1.3.6.tgz", - "integrity": "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-2.0.4.tgz", + "integrity": "sha512-ubuhY5Lo4phWcMsJqe8j62m9uhsuo/VpfK5XsSgYGRGDSt10hwVwOBTriPRd0dac+KYbGNXOrfXjM6xCp2NUKg==", "dev": true, "license": "Apache-2.0" }, @@ -36783,9 +37840,9 @@ } }, "node_modules/wsl-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", + "integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==", "license": "MIT", "dependencies": { "is-wsl": "^3.1.0", @@ -36798,6 +37855,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wsl-utils/node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wtfnode": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/wtfnode/-/wtfnode-0.10.1.tgz", @@ -37173,9 +38242,9 @@ } }, "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==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/zbsearch/-/zbsearch-4.0.0.tgz", + "integrity": "sha512-gm4zfO31n2ZdruTTRQoWVWO4Q2+zrDt2GlrvIc+5JulRQNAm4IanCxER82vQ7Ug96FYkGqWUJBXFe1kGAcDWaQ==", "license": "Apache-2.0", "engines": { "node": ">= 20.0.0" @@ -37213,9 +38282,9 @@ } }, "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", "license": "MIT", "engines": { "node": ">=12.20.0" @@ -37262,7 +38331,7 @@ "playwright": "1.62.1" }, "devDependencies": { - "@types/node": "^22" + "@types/node": "^26" } }, "packages/browser-pool/node_modules/@types/node": { diff --git a/package.json b/package.json index a4998e690f..d6ff6ad849 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.50", - "description": "Unified AI router with 341 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 340 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", @@ -100,6 +100,7 @@ "build:secure": "OMNIROUTE_BUILD_PROFILE=minimal node scripts/build/build-next-isolated.mjs", "build:backend": "cross-env OMNIROUTE_BUILD_BACKEND_ONLY=1 node scripts/build/build-next-isolated.mjs", "build:cli": "node --import tsx scripts/build/prepublish.ts", + "omniroute:verify": "node scripts/check/omniroute-verify.mjs", "build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs", "build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild", "start": "node scripts/dev/run-next.mjs start", @@ -259,31 +260,31 @@ "alibaba:sync-allowlist": "node --import tsx/esm scripts/ops/sync-alibaba-allowlist.mjs" }, "dependencies": { - "@aws-sdk/client-bedrock-runtime": "^3.1107.0", + "@aws-sdk/client-bedrock-runtime": "^3.1111.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@lobehub/icons": "^5.8.0", + "@lobehub/icons": "^5.16.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", "@toon-format/toon": "^4.1.1", "@types/mdx": "^2.0.13", - "@xyflow/react": "^12.11.1", + "@xyflow/react": "^12.11.3", "axios": "^1.19.0", "bcryptjs": "^3.0.3", "bottleneck": "^2.19.5", "clsx": "^2.1.1", "commander": "^15.0.0", - "cron-parser": "^5.8.1", + "cron-parser": "^5.10.0", "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.14.3", - "fumadocs-ui": "^16.14.3", + "fumadocs-core": "^16.14.4", + "fumadocs-ui": "^16.14.4", "http-proxy-middleware": "^4.0.0", "https-proxy-agent": "^9.0.0", "ink": "^7.0.3", @@ -291,21 +292,21 @@ "ink-text-input": "^6.0.0", "ioredis": "^5.10.1", "jose": "^6.2.8", - "js-yaml": "^5.2.3", + "js-yaml": "^5.3.0", "jsonc-parser": "^3.3.1", "lowdb": "^7.0.1", "lucide-react": "^1.21.0", "marked": "^18.0.9", "marked-terminal": "^7.3.0", - "material-symbols": "^0.45.10", + "material-symbols": "^0.46.0", "mermaid": "^11.15.0", "monaco-editor": "^0.56.0", - "next": "16.3.0", + "next": "16.3.1", "next-intl": "^4.13.6", "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.0.2", - "open": "^11.0.0", + "open": "^11.0.1", "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", @@ -321,9 +322,9 @@ "safe-regex": "^2.1.1", "selfsigned": "^5.5.0", "sharp": "^0.35.3", - "smol-toml": "1.7.2", + "smol-toml": "1.8.0", "socks": "^2.8.7", - "sql.js": "^1.14.1", + "sql.js": "^1.14.2", "tailwind-merge": "^3.6.0", "tsx": "^4.23.12", "turndown": "7.2.4", @@ -335,7 +336,7 @@ "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", "zod": "^4.4.3", - "zustand": "^5.0.13", + "zustand": "^5.0.15", "@huggingface/transformers": "^4.2.0", "onnxruntime-node": "~1.24.3" }, @@ -350,14 +351,14 @@ "sqlite-vec": "^0.1.9" }, "devDependencies": { - "@axe-core/playwright": "^4.11.3", - "@cyclonedx/cyclonedx-npm": "6.0.0", + "@axe-core/playwright": "^4.13.0", + "@cyclonedx/cyclonedx-npm": "6.0.1", "@playwright/test": "^1.62.1", "@size-limit/file": "^13.0.3", - "@stryker-mutator/core": "^9.6.1", - "@stryker-mutator/tap-runner": "^9.6.1", + "@stryker-mutator/core": "^10.0.0", + "@stryker-mutator/tap-runner": "^10.0.0", "@tailwindcss/postcss": "^4.3.0", - "@testing-library/jest-dom": "^7.0.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", "@types/better-sqlite3": "^9.6.0", "@types/bun": "latest", @@ -374,22 +375,22 @@ "ctrf": "^0.2.1", "dpdm": "^4.3.0", "eslint": "^9.39.4", - "eslint-config-next": "16.3.0", + "eslint-config-next": "16.3.1", "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", - "fumadocs-mdx": "^15.2.2", + "fumadocs-mdx": "^15.2.3", "glob": "^13.0.6", "httpyac": "^6.16.7", "husky": "^9.1.7", - "jscpd": "^4.2.5", + "jscpd": "^4.3.0", "jsdom": "^30.0.1", "junit-to-ctrf": "^0.0.14", - "knip": "^6.32.0", + "knip": "^6.32.2", "license-checker-rseidelsohn": "^5.0.1", "lint-staged": "^17.3.0", - "lockfile-lint": "^5.0.0", + "lockfile-lint": "^5.0.1", "node-loader": "^2.1.0", - "opencode-ai": "1.18.15", + "opencode-ai": "1.18.18", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.9.6", "promptfoo": "^0.122.0", @@ -397,7 +398,7 @@ "tailwindcss": "^4.3.0", "type-coverage": "^2.30.1", "typescript": "^6.0.3", - "typescript-eslint": "^8.66.0", + "typescript-eslint": "^8.67.0", "vitest": "^4.1.7", "wait-on": "^9.1.0", "wtfnode": "^0.10.1" diff --git a/packages/browser-pool/package.json b/packages/browser-pool/package.json index 7d0355f1b4..b74cee917d 100644 --- a/packages/browser-pool/package.json +++ b/packages/browser-pool/package.json @@ -10,6 +10,6 @@ "playwright": "1.62.1" }, "devDependencies": { - "@types/node": "^22" + "@types/node": "^26" } } diff --git a/scripts/ad-hoc/sync-provider-auto-fetch-i18n-keys.mjs b/scripts/ad-hoc/sync-provider-auto-fetch-i18n-keys.mjs new file mode 100644 index 0000000000..c299ebb18c --- /dev/null +++ b/scripts/ad-hoc/sync-provider-auto-fetch-i18n-keys.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +/** + * One-shot, narrowly-scoped i18n sync for PR #10603. + * + * The full `i18n:sync-ui` tool syncs every missing key against en.json (which + * also picks up an unrelated pre-existing ~33-key backlog per locale). This + * PR only added 11 new keys under `providers.` (autoFetchModels-prefixed, + * overridesUpstreamModel-prefixed, resetToUpstreamDefaults-prefixed), so + * this script translates and inserts only those 11 keys into every locale + * file that is missing them, leaving everything else in each locale file + * byte-identical. Reuses the same translation backend env vars as + * scripts/i18n/sync-ui-keys.mjs (OMNIROUTE_TRANSLATION_API_URL/KEY/MODEL). + * + * Usage: node scripts/ad-hoc/sync-provider-auto-fetch-i18n-keys.mjs + */ + +import { promises as fs, existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(SCRIPT_DIR, "..", ".."); +const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages"); +const CONFIG_PATH = path.join(ROOT, "config", "i18n.json"); +const ENV_PATH = path.join(ROOT, ".env"); + +const TARGET_KEYS = [ + "autoFetchModels", + "autoFetchModelsTooltip", + "autoFetchModelsEnabled", + "autoFetchModelsDisabled", + "autoFetchModelsToggleFailed", + "autoFetchModelsPartialFailure", + "overridesUpstreamModel", + "overridesUpstreamModelHint", + "resetToUpstreamDefaults", + "resetToUpstreamDefaultsSuccess", + "resetToUpstreamDefaultsFailed", +]; +const NAMESPACE = "providers"; + +function loadDotEnv() { + if (!existsSync(ENV_PATH)) return; + const content = readFileSync(ENV_PATH, "utf8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if (!key || process.env[key] !== undefined) continue; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } +} + +function requireEnv(name) { + const v = process.env[name]; + if (!v || !v.trim()) { + throw new Error(`Missing required env var: ${name}`); + } + return v.trim(); +} + +function backendConfig() { + const apiUrl = requireEnv("OMNIROUTE_TRANSLATION_API_URL").replace(/\/$/, ""); + const apiKey = requireEnv("OMNIROUTE_TRANSLATION_API_KEY"); + const model = requireEnv("OMNIROUTE_TRANSLATION_MODEL"); + const timeoutMs = Number(process.env.OMNIROUTE_TRANSLATION_TIMEOUT_MS || 60000); + return { apiUrl, apiKey, model, timeoutMs }; +} + +async function callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry = 0) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(`${apiUrl}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ model, messages, temperature: 0.15, stream: false }), + signal: ctrl.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + const transient = res.status === 408 || res.status === 429 || res.status >= 500; + if (transient && retry < 2) { + const wait = 1500 + retry * 1500; + await new Promise((r) => setTimeout(r, wait)); + return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1); + } + throw new Error(`upstream ${res.status}: ${text.slice(0, 200)}`); + } + const json = await res.json(); + const content = json?.choices?.[0]?.message?.content; + if (typeof content !== "string" || !content) throw new Error("upstream returned empty content"); + return content; + } catch (err) { + if (err?.name === "AbortError") { + if (retry < 2) return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1); + throw new Error(`timeout after ${timeoutMs}ms`); + } + if (retry < 2) { + await new Promise((r) => setTimeout(r, 1500)); + return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +const TRANSLATION_SYSTEM = (englishName, native) => + [ + `You are a professional translator for technical software UI strings.`, + `Translate the user's English UI string into ${englishName} (native: ${native}).`, + `Return ONLY the translated string — no quotes, no commentary, no surrounding markdown.`, + `Preserve placeholders such as {name}, {{count}}, %s, %d, and any HTML tags exactly.`, + `Do NOT translate command names (npm/git/curl/etc), code identifiers, URLs, or environment variable names.`, + `Keep the same casing style (Title Case stays Title Case, sentence case stays sentence case).`, + `Keep punctuation and trailing whitespace identical to the source.`, + ].join(" "); + +async function translateString(englishValue, localeEntry, backend) { + const englishName = localeEntry.english ?? localeEntry.name; + const native = localeEntry.native ?? localeEntry.name; + const messages = [ + { role: "system", content: TRANSLATION_SYSTEM(englishName, native) }, + { role: "user", content: englishValue }, + ]; + const out = await callChat(messages, backend); + return out.trim(); +} + +function createLimiter(max) { + let active = 0; + const queue = []; + const next = () => { + if (!queue.length || active >= max) return; + active++; + const { fn, resolve, reject } = queue.shift(); + fn() + .then((v) => { + active--; + resolve(v); + next(); + }) + .catch((err) => { + active--; + reject(err); + next(); + }); + }; + return (fn) => + new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + next(); + }); +} + +async function main() { + loadDotEnv(); + const backend = backendConfig(); + const config = JSON.parse(await fs.readFile(CONFIG_PATH, "utf8")); + + const en = JSON.parse(await fs.readFile(path.join(MESSAGES_DIR, "en.json"), "utf8")); + const englishValues = Object.fromEntries(TARGET_KEYS.map((k) => [k, en[NAMESPACE][k]])); + for (const [k, v] of Object.entries(englishValues)) { + if (typeof v !== "string") throw new Error(`en.json is missing providers.${k}`); + } + + const onDisk = new Set( + (await fs.readdir(MESSAGES_DIR)).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -5)) + ); + const targetLocales = config.locales + .map((l) => l.code) + .filter((code) => code !== "en" && onDisk.has(code)); + + const limit = createLimiter(Number(process.env.OMNIROUTE_TRANSLATION_CONCURRENCY || 4)); + let filesChanged = 0; + let keysAdded = 0; + + for (const code of targetLocales) { + const localeEntry = config.locales.find((l) => l.code === code); + const localePath = path.join(MESSAGES_DIR, `${code}.json`); + const target = JSON.parse(await fs.readFile(localePath, "utf8")); + if (!target[NAMESPACE] || typeof target[NAMESPACE] !== "object") { + throw new Error(`${code}.json has no "providers" namespace object`); + } + + const missingKeys = TARGET_KEYS.filter( + (k) => typeof target[NAMESPACE][k] !== "string" || target[NAMESPACE][k].length === 0 + ); + if (missingKeys.length === 0) { + console.log(`[sync-provider-i18n] ${code}: already has all 11 keys — skipping`); + continue; + } + + await Promise.all( + missingKeys.map((k) => + limit(async () => { + const translated = await translateString(englishValues[k], localeEntry, backend); + target[NAMESPACE][k] = translated; + }) + ) + ); + + await fs.writeFile(localePath, JSON.stringify(target, null, 2) + "\n", "utf8"); + filesChanged++; + keysAdded += missingKeys.length; + console.log(`[sync-provider-i18n] ${code}: added ${missingKeys.length} keys`); + } + + console.log(`[sync-provider-i18n] done: ${filesChanged} files changed, ${keysAdded} keys added`); +} + +main().catch((err) => { + console.error("[sync-provider-i18n] FAILED:", err); + process.exitCode = 1; +}); diff --git a/scripts/build/colocate-standalone.mjs b/scripts/build/colocate-standalone.mjs index b1bf44f8c0..736527b8dd 100644 --- a/scripts/build/colocate-standalone.mjs +++ b/scripts/build/colocate-standalone.mjs @@ -1,24 +1,15 @@ #!/usr/bin/env node /** - * OmniRoute — Co-locate the LLMLingua-2 runtime into the raw Next standalone build. + * OmniRoute — Co-locate runtime workers into the raw Next standalone build. * * WHY: `npm run build` produces `.build/next/standalone/` and THIS machine's PM2 * deployment runs `server.js` from that directory directly (not the assembled - * `dist/` bundle). The standalone trace: - * - does NOT bundle `open-sse/services/compression/engines/llmlingua/onnxWorker.js` - * (dynamically spawned via worker_threads — untraceable by webpack), and - * - does NOT include the optional SLM deps (`@atjsh/llmlingua-2`, - * `@tensorflow/tfjs`, `js-tiktoken`) — they are optionalDependencies and are - * only installed at the ROOT `node_modules`. + * `dist/` bundle). The standalone trace cannot see worker_threads entrypoints + * resolved at runtime, including the required call-log artifact worker and the + * optional LLMLingua-2 worker. It also omits LLMLingua's optional dependencies. * - * Result: after every plain `npm run build`, the LLMLingua engine silently - * fail-opens (text returned unchanged, no error) because the worker's runtime - * anchors (`process.cwd()` = the standalone dir) find neither the worker file - * nor the deps. This script re-applies both, mirroring what prepublish.ts + - * colocateOptionals.mjs do for the `dist/` bundle. - * - * Idempotent + fail-soft: skips quietly when the optional deps are absent at the - * root (the common slim-install case) and never throws into the build. + * The call-log worker is required, so a bundle failure must fail the build. + * LLMLingua remains fail-soft when its optional dependencies are absent. * * Run manually after a build, or automatically via the `postbuild` npm hook. */ @@ -31,6 +22,8 @@ import { computeDependencyClosure } from "./colocateOptionals.mjs"; const ROOT = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); const STANDALONE = join(ROOT, ".build", "next", "standalone"); +const CALL_LOG_WORKER_REL = join("src", "lib", "usage", "callLogArtifactWorker.js"); +const CALL_LOG_WORKER_SRC = join(ROOT, "src", "lib", "usage", "callLogArtifactWorker.ts"); const WORKER_REL = join( "open-sse", "services", @@ -49,6 +42,22 @@ if (!existsSync(STANDALONE)) { console.log("[colocate-standalone] .build/next/standalone not found — nothing to do."); process.exit(0); } +const callLogWorkerDest = join(STANDALONE, CALL_LOG_WORKER_REL); +mkdirSync(dirname(callLogWorkerDest), { recursive: true }); +execFileSync( + join(ROOT, "node_modules", ".bin", "esbuild"), + [ + CALL_LOG_WORKER_SRC, + "--bundle", + "--platform=node", + "--packages=external", + "--format=esm", + `--outfile=${callLogWorkerDest}`, + ], + { stdio: "inherit" } +); +console.log("[colocate-standalone] ✅ call-log artifact worker bundled"); + if (!hasOptionals) { console.log( "[colocate-standalone] optional SLM deps absent at root node_modules — LLMLingua stays fail-open (slim install)." diff --git a/scripts/build/pack-artifact-policy.ts b/scripts/build/pack-artifact-policy.ts index cd738ea55c..e1076d70fe 100644 --- a/scripts/build/pack-artifact-policy.ts +++ b/scripts/build/pack-artifact-policy.ts @@ -45,6 +45,7 @@ export const APP_STAGING_ALLOWED_EXACT_PATHS: string[] = [ // LLMLingua ONNX worker — esbuild'd standalone .js spawned via worker_threads // (the Next.js bundler can't trace the computed Worker path). Kept like the MCP server. "open-sse/services/compression/engines/llmlingua/onnxWorker.js", + "src/lib/usage/callLogArtifactWorker.js", "package.json", "peer-stamp.mjs", "main-server-timeouts.mjs", @@ -175,6 +176,7 @@ export const PACK_ARTIFACT_ROOT_ALLOWED_PATH_PREFIXES: string[] = [ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [ "dist/open-sse/services/compression/engines/rtk/filters/generic-output.json", + "dist/src/lib/usage/callLogArtifactWorker.js", "dist/open-sse/vendor/codex-chatgpt-web/adapters/chatgpt-web/mcp-server.js", "dist/open-sse/services/compression/rules/en/filler.json", "dist/server.js", diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index e8872c0a9a..340fcba946 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -376,7 +376,29 @@ if (existsSync(chatGptWebCodexMcpSrcFile)) { ); } -// ── Step 8.6: Bundle LLMLingua ONNX worker ──────────────────────────── +// ── Step 8.6: Bundle call-log artifact worker ──────────────────────── +const callLogWorkerSrc = join(ROOT, "src", "lib", "usage", "callLogArtifactWorker.ts"); +const callLogWorkerDest = join(DIST_DIR, "src", "lib", "usage", "callLogArtifactWorker.js"); +if (!existsSync(callLogWorkerSrc)) { + throw new Error("Required call-log artifact worker source is missing"); +} +console.log(" 🔨 Bundling call-log artifact worker..."); +mkdirSync(dirname(callLogWorkerDest), { recursive: true }); +runBuildTool( + "esbuild", + "esbuild", + [ + "src/lib/usage/callLogArtifactWorker.ts", + "--bundle", + "--platform=node", + "--packages=external", + "--format=esm", + "--outfile=dist/src/lib/usage/callLogArtifactWorker.js", + ], + { cwd: ROOT, stdio: "inherit" } +); + +// ── Step 8.6a: Bundle LLMLingua ONNX worker ─────────────────────────── // The worker is spawned via worker_threads at a path the Next.js bundler cannot // statically trace, so it must ship as a standalone .js (mirrors the MCP-server // bundling above). Heavy deps (@atjsh/llmlingua-2 / @huggingface/transformers / diff --git a/scripts/check/check-fabricated-docs.mjs b/scripts/check/check-fabricated-docs.mjs index aba493b7d4..0a1d043032 100644 --- a/scripts/check/check-fabricated-docs.mjs +++ b/scripts/check/check-fabricated-docs.mjs @@ -114,6 +114,12 @@ const ENV_VAR_ALLOWLIST = new Set([ "LINUX_GPG_KEY", // electron AppImage signing key, CI/build only (ELECTRON_GUIDE.md) "BRANCH_LOCK_TOKEN", // release branch-protection ops token (QUALITY_GATE_PLAYBOOK.md) "NEXT_LOCALE", // next-intl locale cookie name (I18N.md) + // Feature flags are resolved by key at runtime — `resolveFeatureFlag()` reads + // `process.env[key]` (src/shared/utils/featureFlags.ts), never a literal + // `process.env.MODELS_CATALOG_PREFIX_MODE`, so this scan cannot see the read. + // The flag is real: defined in featureFlagDefinitions.ts, overridable from the + // dashboard or the environment. (API_REFERENCE.md, VSCODE-COPILOT.md) + "MODELS_CATALOG_PREFIX_MODE", // Telegram Mini App integration (proposal TELEGRAM-MINIAPP.md, not yet implemented): env vars named in the feasibility analysis but no code reads them yet. "TELEGRAM_WEBHOOK_URL", // proposal-only: Telegram webhook public endpoint (TELEGRAM-MINIAPP.md, future feature) "TELEGRAM_WEBHOOK_SECRET", // proposal-only: Telegram webhook HMAC secret (TELEGRAM-MINIAPP.md, future feature) diff --git a/scripts/check/check-migration-numbering.mjs b/scripts/check/check-migration-numbering.mjs index b377c1af74..86799b51b3 100644 --- a/scripts/check/check-migration-numbering.mjs +++ b/scripts/check/check-migration-numbering.mjs @@ -45,17 +45,13 @@ export const KNOWN_DUPLICATE_VERSIONS = new Set([ // Fonte: auditoria do disco (src/lib/db/migrations/). Além dos slots legados, // As migrations Radar 144–145, a migration 143 e a 147 já aterrissaram. O job // registry foi promovido de 139 para 146 pela tabela -// RENAMED_MIGRATION_COMPATIBILITY. A 149 aterrissa junto com #10066 -// (149_api_key_combo_access.sql). 148 permanece reservada por PRs #10001 e -// #10047 ainda em trânsito. O stale-enforcement exige que cada reserva seja -// removida quando os arquivos correspondentes aterrissarem na release. +// RENAMED_MIGRATION_COMPATIBILITY. A 148 aterrissou nesta branch +// (148_provider_quota_state.sql) e a 149 aterrissou junto com #10066 +// (149_api_key_combo_access.sql) — nenhuma das duas é mais um gap. O +// stale-enforcement exige que cada reserva seja removida quando os arquivos +// correspondentes aterrissarem na release. // --------------------------------------------------------------------------- -export const KNOWN_GAPS = new Set([ - "026", - "055", - "121", // número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12) - "148", // reserved by open PRs #10001 and #10047 -]); +export const KNOWN_GAPS = new Set(["026", "055", "121"]); // 121: número queimado no ciclo v3.8.47 — 122 (#6909) mergeou antes e 121 nunca aterrissou (validação e2e 2026-07-12); 144/145 aterrissaram na release (radar offers/intel cache), 148/149 aterrissaram (provider_quota_state, api_key_combo_access) function pad3(n) { return String(n).padStart(3, "0"); diff --git a/scripts/check/omniroute-verify.mjs b/scripts/check/omniroute-verify.mjs new file mode 100644 index 0000000000..8082f499da --- /dev/null +++ b/scripts/check/omniroute-verify.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import { CLI_TOKEN_HEADER, getCliToken } from "../../bin/cli/utils/cliToken.mjs"; + +const baseUrl = (process.env.OMNIROUTE_BASE_URL || "http://127.0.0.1:20128").replace(/\/$/, ""); +const apiKey = process.env.OMNIROUTE_API_KEY || ""; +const timeoutMs = 5000; + +async function get(path) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let hardTimer; + const hardTimeout = new Promise((_, reject) => { + hardTimer = setTimeout( + () => reject(new Error(`request timeout after ${timeoutMs}ms`)), + timeoutMs + 100 + ); + }); + try { + const response = await Promise.race([ + fetch(`${baseUrl}${path}`, { + headers: { + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + [CLI_TOKEN_HEADER]: await getCliToken(), + }, + signal: controller.signal, + }), + hardTimeout, + ]); + const body = await response.json().catch(() => null); + return { ok: response.ok, status: response.status, body }; + } finally { + clearTimeout(timer); + clearTimeout(hardTimer); + } +} + +function check(label, passed, detail = "") { + console.log(`${label}: ${passed ? "PASS" : "FAIL"}${detail ? ` (${detail})` : ""}`); + return passed; +} + +console.log("OmniRoute Verification"); +console.log(`Gateway: ${baseUrl}`); +const results = []; + +try { + const models = await get("/v1/models"); + results.push(check("Gateway", models.ok, `HTTP ${models.status}`)); + const modelCount = Array.isArray(models.body?.data) ? models.body.data.length : 0; + results.push(check("Catalog", modelCount > 0, `${modelCount} models`)); + + const pools = await get("/api/quota/pools"); + const poolRows = Array.isArray(pools.body?.pools) ? pools.body.pools : []; + const allocations = poolRows.reduce((sum, pool) => sum + (pool.allocations?.length || 0), 0); + results.push(check("Pools", pools.ok, `${poolRows.length}`)); + results.push(check("Allocations", pools.ok && allocations >= poolRows.length, `${allocations}`)); + + const status = await get("/api/omniroute/status"); + results.push(check("Status API", status.ok, `HTTP ${status.status}`)); + results.push(check("No live request", status.body?.liveRequestExecuted === false)); +} catch (error) { + results.push( + check("Verification", false, error instanceof Error ? error.message : String(error)) + ); +} + +console.log(`Live upstream requests: 0`); +if (results.some((passed) => !passed)) process.exitCode = 1; diff --git a/scripts/dev/healthcheck.mjs b/scripts/dev/healthcheck.mjs index 6124b83a81..67a6e54c73 100644 --- a/scripts/dev/healthcheck.mjs +++ b/scripts/dev/healthcheck.mjs @@ -8,6 +8,15 @@ * event loop is busy (#10052) and can restart the only replica mid-session. * Used by Dockerfile and docker-compose files. * + * #10311 — the container HEALTHCHECK previously probed the heavy + * /api/monitoring/health path (synchronous SQLite reads + deep monitoring + * aggregation) on the same single-process event loop as catalog rebuild / + * long-context compression. Under load that probe could stall past the 5s + * timeout and flip the container `unhealthy`, restarting it mid-session and + * killing active SSE streams. /healthz is a pure in-memory lifecycle check + * with no DB access. Operators who want the deep monitoring probe can opt + * back in with OMNIROUTE_HEALTHCHECK_PATH. + * * #3151 — in some Docker network setups the server binds to a container IP and * a probe against `127.0.0.1` is not reachable, while `localhost`/`::1` (or vice * versa) is. The previous version probed ONLY `127.0.0.1` and swallowed every @@ -35,10 +44,34 @@ function normalizeBasePath(value) { return `/${segments.join("/")}`; } -/** Prefixes the health route with the configured Next.js basePath. */ -export function resolveHealthPath(basePathValue) { +/** + * Normalize an explicit health-check path override (OMNIROUTE_HEALTHCHECK_PATH). + * Returns "" when absent/invalid so callers fall back to DEFAULT_HEALTH_PATH. + * Mirrors normalizeBasePath's safety rules (no query/hash/backslash, no "." / + * ".." segments, must start with "/"). + */ +function normalizeHealthPath(value) { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (!trimmed) return ""; + if (!trimmed.startsWith("/") || /[?#\\]/.test(trimmed)) return ""; + const segments = trimmed.split("/").filter(Boolean); + if (segments.some((segment) => segment === "." || segment === "..")) return ""; + return `/${segments.join("/")}`; +} + +/** + * Resolve the health route to probe. By default the lightweight /healthz + * lifecycle endpoint (pure in-memory, no DB reads). An explicit + * OMNIROUTE_HEALTHCHECK_PATH override opts back into the deep monitoring + * probe. The configured Next.js basePath is always prefixed. + * + * @param {string} [basePathValue] value of OMNIROUTE_BASE_PATH + * @param {string} [healthPathValue] value of OMNIROUTE_HEALTHCHECK_PATH + */ +export function resolveHealthPath(basePathValue, healthPathValue) { const basePath = normalizeBasePath(basePathValue); - return basePath ? `${basePath}${DEFAULT_HEALTH_PATH}` : DEFAULT_HEALTH_PATH; + const healthPath = normalizeHealthPath(healthPathValue) || DEFAULT_HEALTH_PATH; + return basePath ? `${basePath}${healthPath}` : healthPath; } /** @@ -118,7 +151,10 @@ async function main() { } try { - const healthPath = resolveHealthPath(process.env.OMNIROUTE_BASE_PATH); + const healthPath = resolveHealthPath( + process.env.OMNIROUTE_BASE_PATH, + process.env.OMNIROUTE_HEALTHCHECK_PATH + ); await probeHealth({ port, hosts, healthPath }); process.exit(0); } catch (err) { diff --git a/scripts/perf/video-bridge-bench.ts b/scripts/perf/video-bridge-bench.ts new file mode 100644 index 0000000000..6e9a18337b --- /dev/null +++ b/scripts/perf/video-bridge-bench.ts @@ -0,0 +1,90 @@ +/** + * Video Bridge benchmarks (VB-FU-07 sampler overhead + VB-FU-09 contact sheet A/B). + * + * Run: node --import tsx/esm scripts/perf/video-bridge-bench.ts + * + * 1. Sampler: measures the pure timestamp-selection cost of uniform vs + * scene_aware vs segment_aware for growing scene-candidate counts. The + * ffmpeg scene-detection pass is shared by both aware policies and is + * I/O-bound, so the incremental policy cost is exactly this selection step. + * 2. Contact sheet: composes synthetic JPEG frames into the timestamped grid + * and compares payload bytes + model calls against individual frames. + */ +import { performance } from "node:perf_hooks"; + +import { buildVideoContactSheet } from "../../src/lib/guardrails/videoBridgeContactSheet"; +import { + calculateSamplingDecision, + type VideoSamplingPolicy, +} from "../../src/lib/guardrails/videoBridgeRuntime"; + +const SAMPLER_ITERATIONS = 2_000; + +function benchSampler(): void { + console.log("== Sampler timestamp-selection cost (pure, per call) =="); + console.log("duration frames candidates | uniform scene_aware segment_aware (µs/op)"); + for (const durationSeconds of [60, 600]) { + for (const frameCount of [8, 16]) { + for (const candidateCount of [0, 16, 128, 512]) { + const candidates = Array.from( + { length: candidateCount }, + (_unused, index) => ((index + 1) * durationSeconds) / (candidateCount + 1) + ); + const row: string[] = []; + for (const policy of ["uniform", "scene_aware", "segment_aware"] as VideoSamplingPolicy[]) { + const start = performance.now(); + for (let iteration = 0; iteration < SAMPLER_ITERATIONS; iteration++) { + calculateSamplingDecision(durationSeconds, frameCount, policy, candidates, null); + } + const microsPerOp = ((performance.now() - start) * 1000) / SAMPLER_ITERATIONS; + row.push(microsPerOp.toFixed(1)); + } + console.log( + `${String(durationSeconds).padStart(5)}s ${String(frameCount).padStart(5)} ${String(candidateCount).padStart(10)} | ${row.join(" ")}` + ); + } + } + } +} + +async function syntheticJpegFrame(index: number): Promise { + const { default: sharp } = await import("sharp"); + const buffer = await sharp({ + create: { + width: 512, + height: 288, + channels: 3, + background: { r: (index * 37) % 255, g: (index * 91) % 255, b: (index * 53) % 255 }, + }, + }) + .jpeg({ quality: 80 }) + .toBuffer(); + return `data:image/jpeg;base64,${buffer.toString("base64")}`; +} + +async function benchContactSheet(): Promise { + console.log("\n== Contact sheet vs individual frames (synthetic 512x288 JPEG) =="); + console.log("frames | sheet_ms sheet_KiB individual_KiB model_calls(sheet/individual)"); + for (const frameCount of [1, 4, 8, 16]) { + const frames = await Promise.all( + Array.from({ length: frameCount }, async (_unused, index) => ({ + dataUri: await syntheticJpegFrame(index), + timestampSeconds: index * 2, + })) + ); + const individualBytes = frames.reduce((sum, frame) => sum + frame.dataUri.length, 0); + const start = performance.now(); + const sheet = await buildVideoContactSheet(frames, { timeoutMs: 30_000 }); + const elapsedMs = performance.now() - start; + const sheetBytes = sheet.used && sheet.dataUri ? sheet.dataUri.length : individualBytes; + console.log( + `${String(frameCount).padStart(6)} | ${elapsedMs.toFixed(1).padStart(8)} ${(sheetBytes / 1024).toFixed(1).padStart(9)} ${(individualBytes / 1024).toFixed(1).padStart(14)} ${sheet.used ? 1 : frameCount}/${frameCount}` + ); + if (!sheet.used) { + console.log(` fallbackReason=${sheet.fallbackReason ?? "unknown"}`); + } + } +} + +benchSampler(); +await benchContactSheet(); diff --git a/skills/cli-contexts/SKILL.md b/skills/cli-contexts/SKILL.md index c25640c92f..ae23e91f53 100644 --- a/skills/cli-contexts/SKILL.md +++ b/skills/cli-contexts/SKILL.md @@ -325,6 +325,20 @@ Import contexts from a JSON file omniroute contexts import ``` +### `contexts migrate` + +Move legacy plaintext context credentials to the OS keychain + +**Flags:** + +- `--yes` + +**Example:** + +```bash +omniroute contexts migrate +``` + ### `sessions` **Example:** diff --git a/src/app/(dashboard)/dashboard/conversations/page.tsx b/src/app/(dashboard)/dashboard/conversations/page.tsx new file mode 100644 index 0000000000..78c352c175 --- /dev/null +++ b/src/app/(dashboard)/dashboard/conversations/page.tsx @@ -0,0 +1,954 @@ +"use client"; + +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { PROVIDER_COLORS, getHttpStatusStyle } from "@/shared/constants/colors"; +import { formatTime } from "@/shared/utils/formatting"; +import { copyToClipboard } from "@/shared/utils/clipboard"; +import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { ChatBubble } from "@/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble"; +import type { NormalizedBlock, NormalizedTurn } from "@/mitm/inspector/types"; + +interface ConversationRow { + id: string; + turnCount: number; + firstSeenAt: string; + lastSeenAt: string; + lastCallLogId: string | null; + lastModel: string | null; + lastProvider: string | null; + lastStatus: number | null; + isActive: boolean; + // The in-flight request's OWN id (from usageHistory's pendingById, keyed by + // sessionTag) — distinct from lastCallLogId, which joins against call_logs + // and therefore always lags one request behind while a reply is still + // streaming (call_logs only gets its row on completion). Used to poll + // /api/logs/[id] for this conversation's live partial assistant text. + activeCallLogId: string | null; +} + +// Same spinner used for an in-flight request on /dashboard/logs +// (RequestLoggerV2) — reused here so "in progress" reads the same way in +// both places. +function ActiveSpinner() { + return ( + + + + ); +} + +interface ConversationTurn { + seq: number; + id: string; + parentId: string | null; + role: string; + textPreview: string; + blockKind: string; + toolName: string | null; + firstSeenAt: string; +} + +interface ConversationTurnsPage { + nodes: ConversationTurn[]; + hasMore: boolean; +} + +const CONVERSATION_PAGE_SIZE = 20; + +const DEFAULT_POLL_SECONDS = 5; +const POLL_STORAGE_KEY = "conversationsListPollSeconds"; +// Matches RequestLoggerDetail's CONVERSATION_ACTIVE_POLL_INTERVAL_MS — same +// live-partial-text source, same cadence, so the two views feel consistent. +const LIVE_TEXT_POLL_INTERVAL_MS = 1200; + +function ProviderBadge({ provider }: { provider: string | null }) { + if (!provider) return ; + const style = (PROVIDER_COLORS as Record)[ + provider + ]; + if (!style) { + return ( + + {provider} + + ); + } + return ( + + {style.label} + + ); +} + +function StatusBadge({ status }: { status: number | null }) { + if (status == null) return ; + const style = getHttpStatusStyle(status); + return ( + + {status} + + ); +} + +/** + * Builds the exact NormalizedBlock (src/mitm/inspector/types.ts) the + * request-detail panel already builds from buildRequestTurns/ + * buildResponseTurns, so a tool call/result renders through the very same + * ChatBubble → MessageContent → ToolCallBlock/ToolResultBlock pipeline as + * the detail view — not a parallel implementation. `textPreview` round- + * tripped through JSON for a structured tool_use/tool_result turn; parse it + * best-effort so the block gets a real object, not a JSON string. + */ +function toTurn(node: ConversationTurn): NormalizedTurn { + const role: NormalizedTurn["role"] = + node.role === "system" || node.role === "user" || node.role === "assistant" + ? node.role + : "tool"; + + let block: NormalizedBlock; + if (node.blockKind === "tool_use") { + let input: unknown = node.textPreview; + try { + input = JSON.parse(node.textPreview); + } catch { + // Arguments weren't valid JSON — show the raw string. + } + block = { type: "tool_use", id: node.id.slice(0, 12), name: node.toolName ?? "tool", input }; + } else if (node.blockKind === "tool_result") { + let content: unknown = node.textPreview; + try { + content = JSON.parse(node.textPreview); + } catch { + // Not JSON — show the raw string. + } + block = { type: "tool_result", tool_use_id: node.id.slice(0, 12), content }; + } else { + block = { type: "text", text: node.textPreview || "_(empty)_" }; + } + + return { role, blocks: [block], timestamp: node.firstSeenAt }; +} + +/** + * Renders a conversation's turns top to bottom, oldest first — always a + * flat, chronological list. Every OmniRoute conversation is a single + * straight line (an edited/duplicated turn mints its own independent + * conversation instead of branching this one — see conversationTracker.ts's + * 2026-08-06 redesign), so there is no fork/indentation logic here at all + * anymore. `onLoadOlder` renders as a button above the turns when more + * (older) history exists than the current page. + */ +function ConversationLogView({ + nodes, + hasMore, + loadingMore, + onLoadOlder, + livePartialText, +}: { + nodes: ConversationTurn[]; + hasMore: boolean; + loadingMore: boolean; + onLoadOlder: () => void; + // The reply currently streaming for this conversation, if any — not yet a + // persisted conversation_turn_nodes row (see the live-text poll effect's + // comment for why), rendered as a provisional bubble below the real turns. + livePartialText: string; +}) { + if (nodes.length === 0 && !livePartialText) { + return ( +
No turns recorded for this conversation.
+ ); + } + return ( +
+ {hasMore && ( + + )} + {nodes.map((node) => ( + + ))} + {livePartialText && ( +
+
+ + Generating… +
+ +
+ )} +
+ ); +} + +function ConversationsPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + // Read once on mount, mirroring dashboard/logs/page.tsx (#6830/#8354): re-reading the + // live searchParams on every render re-fires the deep-link open effect right when the + // panel closes and router.replace() strips the ?id= param. + const [initialId] = useState(() => searchParams.get("id")); + // Deep link for the conversation modal — separate param from `id` (the + // request-detail panel) so either overlay can be linked independently. + const [initialConversationParam] = useState(() => searchParams.get("tree")); + + const [conversations, setConversations] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + + const { emailsVisible } = useEmailPrivacyStore(); + const [selectedLog, setSelectedLog] = useState(null); + const [detailData, setDetailData] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); + const [activeConversation, setActiveConversation] = useState(null); + // Extracted so effects that only care "which conversation" (not its + // summary fields) can depend on this stable primitive instead of the + // whole activeConversation object — that object gets a fresh reference + // every list-poll tick once opened (see the resync effect below), which + // would otherwise rebind timers/listeners on every poll tick. + const activeConversationId = activeConversation?.id ?? null; + // Only the identifier, not the whole activeConversation object, for the same + // reason as activeConversationId above: this changes identity every list-poll + // tick, which would otherwise tear down/restart the live-text poll effect. + const activeCallLogId = activeConversation?.activeCallLogId ?? null; + const [livePartialText, setLivePartialText] = useState(""); + const [conversationNodes, setConversationNodes] = useState([]); + const [conversationLoading, setConversationLoading] = useState(false); + const [conversationHasMore, setConversationHasMore] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [pollSeconds, setPollSeconds] = useState(() => { + try { + const saved = localStorage.getItem(POLL_STORAGE_KEY); + const parsed = saved ? Number(saved) : DEFAULT_POLL_SECONDS; + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_POLL_SECONDS; + } catch { + return DEFAULT_POLL_SECONDS; + } + }); + const initialOpenedRef = useRef(false); + const initialConversationOpenedRef = useRef(false); + const conversationPanelRef = useRef(null); + const conversationContentRef = useRef(null); + // True right after opening a conversation (or clicking "Go to bottom"), + // cleared once the user scrolls away from the bottom themselves. A large + // conversation's last page can include multi-KB tool-output/context turns + // whose markdown takes more than one animation frame to lay out, so a + // single scrollTop=scrollHeight right after fetch can undershoot — the + // ResizeObserver below re-pins on every subsequent layout change while + // this stays true, instead of a one-shot scroll that races the render. + const pinnedToBottomRef = useRef(false); + // Set right before prepending an older page, so the effect below can + // adjust scrollTop by exactly how much content grew above the fold — + // otherwise "Load more" would visually yank the view to the top. + const prependAdjustRef = useRef<{ prevScrollHeight: number; prevScrollTop: number } | null>(null); + // Mirrors the newest loaded turn's seq without needing conversationNodes + // itself in the poll effect's dependency array (which would tear down and + // restart the interval on every single appended turn). + const newestSeqRef = useRef(null); + + // Extracted so openConversation can force an immediate refresh instead of + // waiting for the next scheduled tick — see its call site for why: a + // conversation opened right after a new reply starts streaming otherwise + // shows no live text until this poll's own interval happens to land, + // because activeCallLogId only updates via the resync effect below, which + // depends on this list actually having been refetched. + const loadConversations = useCallback(() => { + if (document.visibilityState !== "visible") return; + return fetch("/api/conversations?limit=100", { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setConversations(Array.isArray(data.conversations) ? data.conversations : []); + setTotal(typeof data.total === "number" ? data.total : 0); + }) + .catch(() => {}) + .finally(() => { + setLoading(false); + }); + }, []); + + useEffect(() => { + loadConversations(); + const interval = setInterval(loadConversations, pollSeconds * 1000); + return () => { + clearInterval(interval); + }; + }, [pollSeconds, loadConversations]); + + // activeConversation is a snapshot taken once at openConversation() time — + // it's never touched again while the modal stays open (the turns-poll + // effect below only appends conversationNodes). Without this, "Goto latest + // request" and any other displayed summary field (lastModel/lastStatus/ + // turnCount) go stale the moment a new request lands in this conversation + // while you're still reading it, even though the list poll above (which + // runs regardless of whether the modal is open) already has the fresh + // row. Re-sync from it whenever the list refreshes. + useEffect(() => { + if (!activeConversationId) return; + const fresh = conversations.find((c) => c.id === activeConversationId); + if (!fresh) return; + setActiveConversation((prev) => (prev && prev.id === fresh.id ? fresh : prev)); + }, [conversations, activeConversationId]); + + useEffect(() => { + fetch("/api/logs/detail?limit=1") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setDetailLoggingEnabled(data.enabled === true); + }) + .catch(() => {}); + }, []); + + // Opens a request's detail panel in-place — used for the initial row click and for + // every subsequent turn/next-message navigation, so viewing a conversation never + // navigates away from this page (matches RequestLoggerV2/RequestTimeline). + const openById = useCallback( + async (id: string) => { + try { + const url = new URL(globalThis.location.href); + url.searchParams.set("id", id); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + setDetailLoading(true); + try { + const res = await fetch(`/api/logs/${id}`, { cache: "no-store" }); + const data = res.ok ? await res.json() : null; + if (data) { + setSelectedLog({ + id: data.id ?? id, + timestamp: data.timestamp, + status: data.status ?? 0, + model: data.model ?? null, + provider: data.provider ?? null, + account: data.account ?? null, + duration: data.duration ?? 0, + tokens: data.tokens ?? { in: 0, out: 0 }, + active: data.active, + error: data.error ?? null, + path: data.path ?? null, + }); + setDetailData(data); + } + } catch { + // ignore fetch errors + } finally { + setDetailLoading(false); + } + }, + [router] + ); + + const closeDetail = useCallback(() => { + setSelectedLog(null); + setDetailData(null); + try { + const url = new URL(globalThis.location.href); + url.searchParams.delete("id"); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + }, [router]); + + useEffect(() => { + if (!initialId || initialOpenedRef.current) return; + initialOpenedRef.current = true; + openById(initialId).catch(() => {}); + }, [initialId, openById]); + + const scrollToBottom = useCallback(() => { + pinnedToBottomRef.current = true; + const el = conversationPanelRef.current; + if (!el) return; + requestAnimationFrame(() => { + try { + el.scrollTop = el.scrollHeight; + } catch {} + }); + }, []); + + // Keeps the panel pinned to its bottom while conversationContentRef's + // height keeps changing (initial render of a large page, late-settling + // markdown/tool-output layout, a new turn arriving via poll) — see + // pinnedToBottomRef's comment above for why a single scrollToBottom call + // isn't enough on its own for a heavy page. + useEffect(() => { + const content = conversationContentRef.current; + const panel = conversationPanelRef.current; + if (!content || !panel) return; + const observer = new ResizeObserver(() => { + if (!pinnedToBottomRef.current) return; + panel.scrollTop = panel.scrollHeight; + }); + observer.observe(content); + return () => observer.disconnect(); + // Keyed on the id, not the whole object: activeConversation's summary + // fields (lastCallLogId etc.) get resynced from the list poll while the + // modal stays open (see that effect's comment), which would otherwise + // tear down and recreate this observer on every poll tick. + }, [activeConversation?.id]); + + // Un-pin as soon as the user scrolls away from the bottom themselves (e.g. + // to read earlier turns or click "Load more"), so later content growth + // doesn't yank them back down against their will. Re-pins automatically if + // they scroll back down to the bottom on their own. + useEffect(() => { + const panel = conversationPanelRef.current; + if (!panel) return; + const NEAR_BOTTOM_PX = 24; + const onScroll = () => { + const distanceFromBottom = panel.scrollHeight - panel.scrollTop - panel.clientHeight; + pinnedToBottomRef.current = distanceFromBottom <= NEAR_BOTTOM_PX; + }; + panel.addEventListener("scroll", onScroll, { passive: true }); + return () => panel.removeEventListener("scroll", onScroll); + }, [activeConversation?.id]); + + const fetchConversationPage = useCallback( + (id: string, params: string): Promise => + fetch(`/api/conversations/${id}/tree?${params}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => + data && Array.isArray(data.nodes) + ? { nodes: data.nodes, hasMore: Boolean(data.hasMore) } + : null + ) + .catch(() => null), + [] + ); + + const openConversation = useCallback( + (row: ConversationRow) => { + setActiveConversation(row); + setConversationNodes([]); + setConversationHasMore(false); + setConversationLoading(true); + setLivePartialText(""); + try { + const url = new URL(globalThis.location.href); + url.searchParams.set("tree", row.id); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + // `row` is a snapshot from whenever the list last polled — if a reply + // started streaming after that tick, row.activeCallLogId is still + // null and the live-text poll effect never starts until the next + // scheduled list refresh happens to land (the exact "opened it and + // saw nothing, closed and reopened and saw it live" report). Force + // one now so activeConversation resyncs with the current isActive/ + // activeCallLogId immediately instead of waiting on pollSeconds. + loadConversations(); + fetchConversationPage(row.id, `limit=${CONVERSATION_PAGE_SIZE}`) + .then((page) => { + setConversationNodes(page?.nodes ?? []); + setConversationHasMore(page?.hasMore ?? false); + }) + .finally(() => { + setConversationLoading(false); + // A freshly-opened conversation should start scrolled to the + // latest (bottom-most) turn, not the oldest one on the page. + scrollToBottom(); + }); + }, + [router, fetchConversationPage, scrollToBottom, loadConversations] + ); + + const closeConversation = useCallback(() => { + setActiveConversation(null); + try { + const url = new URL(globalThis.location.href); + url.searchParams.delete("tree"); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors + } + }, [router]); + + const loadOlderTurns = useCallback(() => { + const panel = conversationPanelRef.current; + const oldestSeq = conversationNodes[0]?.seq; + if (!activeConversation || !panel || oldestSeq == null || loadingOlder) return; + setLoadingOlder(true); + prependAdjustRef.current = { + prevScrollHeight: panel.scrollHeight, + prevScrollTop: panel.scrollTop, + }; + fetchConversationPage( + activeConversation.id, + `limit=${CONVERSATION_PAGE_SIZE}&beforeSeq=${oldestSeq}` + ) + .then((page) => { + if (page && page.nodes.length > 0) { + setConversationNodes((prev) => [...page.nodes, ...prev]); + } + setConversationHasMore(page?.hasMore ?? false); + }) + .finally(() => setLoadingOlder(false)); + }, [activeConversation, conversationNodes, loadingOlder, fetchConversationPage]); + + // Preserve scroll position across a "load more" prepend — otherwise + // adding older turns above the fold visually yanks the view to the top. + useEffect(() => { + const adjust = prependAdjustRef.current; + if (!adjust) return; + prependAdjustRef.current = null; + const panel = conversationPanelRef.current; + if (!panel) return; + requestAnimationFrame(() => { + panel.scrollTop = adjust.prevScrollTop + (panel.scrollHeight - adjust.prevScrollHeight); + }); + }, [conversationNodes]); + + useEffect(() => { + newestSeqRef.current = + conversationNodes.length > 0 ? conversationNodes[conversationNodes.length - 1].seq : null; + }, [conversationNodes]); + + useEffect(() => { + if (!activeConversationId) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") closeConversation(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + // activeConversationId, not the whole activeConversation object: it + // gets resynced (new object reference) from the list poll while the + // modal stays open (see that effect's comment) — depending on the + // object here would rebind this listener on every poll tick for no + // reason. + }, [activeConversationId, closeConversation]); + + // While the conversation is open, keep polling for turns that arrive + // later (the request that opened it may not be the last one — OpenClaw + // can send another turn while you're still reading). Reuses the same + // "Auto-refresh Xs" setting as the list, rather than a separate interval, + // so there's one poll cadence to reason about on this page. Only ever + // APPENDS newer turns (via afterSeq) — it never re-fetches or replaces + // the whole page, so a "Load more" page loaded earlier stays put, and it + // deliberately does NOT re-scroll on every refresh (only the initial open + // does that), so it doesn't yank the view mid-read. + // + // Depends on activeConversationId, NOT the whole activeConversation + // object: activeConversation gets a fresh object reference every list-poll + // tick (see the resync effect above, needed so "Goto latest request" + // doesn't go stale) — on the SAME poll cadence as this effect's own + // interval. Depending on the object would tear down and recreate this + // setInterval every single tick, resetting its countdown each time and + // starving it of ever actually firing — silently breaking the exact + // "keep filling in new turns while open" behavior this effect exists for. + useEffect(() => { + if (!activeConversationId) return; + const tick = () => { + if (document.visibilityState !== "visible") return; + if (newestSeqRef.current == null) return; + fetchConversationPage(activeConversationId, `afterSeq=${newestSeqRef.current}`).then( + (page) => { + if (page && page.nodes.length > 0) { + setConversationNodes((prev) => [...prev, ...page.nodes]); + } + } + ); + }; + const interval = setInterval(tick, pollSeconds * 1000); + return () => clearInterval(interval); + }, [activeConversationId, pollSeconds, fetchConversationPage]); + + // Live preview of the CURRENTLY streaming reply, if any: conversation_turn_nodes + // only gains a node for an assistant turn once the client resends it as + // history on its NEXT request (resolveConversationId reads only the request + // body), so the turns-poll effect above has nothing new to fetch while a + // reply is still generating — the transcript would sit frozen despite the + // request actively producing text. Same live-partial-text source + // RequestLoggerDetail's ConversationContextSection already polls + // (/api/logs/[id]'s partialAssistantText, built from in-flight streamChunks), + // rendered here as a provisional bubble that's never written to + // conversationNodes/DB. Uses a short fixed interval (not the user's + // Auto-refresh Xs list-poll setting) since a still-generating reply is worth + // refreshing faster than "is there a new conversation" — matches + // RequestLoggerDetail's CONVERSATION_ACTIVE_POLL_INTERVAL_MS. + useEffect(() => { + if (!activeCallLogId) { + setLivePartialText(""); + return; + } + let cancelled = false; + let timeoutId: ReturnType | undefined; + + const tick = () => { + if (cancelled) return; + if (document.visibilityState !== "visible") { + timeoutId = setTimeout(tick, LIVE_TEXT_POLL_INTERVAL_MS); + return; + } + fetch(`/api/logs/${activeCallLogId}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (cancelled || !data) return; + setLivePartialText( + typeof data.partialAssistantText === "string" ? data.partialAssistantText : "" + ); + if (data.active) timeoutId = setTimeout(tick, LIVE_TEXT_POLL_INTERVAL_MS); + }) + .catch(() => { + timeoutId = setTimeout(tick, LIVE_TEXT_POLL_INTERVAL_MS); + }); + }; + + timeoutId = setTimeout(tick, LIVE_TEXT_POLL_INTERVAL_MS); + return () => { + cancelled = true; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [activeCallLogId]); + + // Deep link: /dashboard/conversations?tree= opens that conversation. + // Prefer the already-loaded row (has lastCallLogId for "Goto latest + // request"); fall back to a minimal row if the conversation isn't in the + // current page of the list (still fully works — the API only needs the + // id). + useEffect(() => { + if (!initialConversationParam || initialConversationOpenedRef.current || loading) return; + initialConversationOpenedRef.current = true; + const found = conversations.find((c) => c.id === initialConversationParam); + openConversation( + found ?? { + id: initialConversationParam, + turnCount: 0, + firstSeenAt: "", + lastSeenAt: "", + lastCallLogId: null, + lastModel: null, + lastProvider: null, + lastStatus: null, + isActive: false, + activeCallLogId: null, + } + ); + }, [initialConversationParam, loading, conversations, openConversation]); + + const gotoLatestRequest = () => { + const id = activeConversation?.lastCallLogId; + if (!id) return; + closeConversation(); + openById(id).catch(() => {}); + }; + + // Previous/Next navigate to the adjacent row in the currently loaded list — + // same idea as RequestLoggerDetail's onPrevious/onNext, but one level up + // (between conversations, not between requests within one). Index is + // recomputed from `conversations` on every click rather than memoized: the + // list refreshes under a poll while the modal is open (see the resync + // effect above), so a stale captured index could skip/repeat a row. + const activeConversationIndex = activeConversation + ? conversations.findIndex((c) => c.id === activeConversation.id) + : -1; + const hasPreviousConversation = activeConversationIndex > 0; + const hasNextConversation = + activeConversationIndex !== -1 && activeConversationIndex < conversations.length - 1; + + const goToPreviousConversation = useCallback(() => { + const index = conversations.findIndex((c) => c.id === activeConversation?.id); + if (index <= 0) return; + openConversation(conversations[index - 1]); + }, [conversations, activeConversation, openConversation]); + + const goToNextConversation = useCallback(() => { + const index = conversations.findIndex((c) => c.id === activeConversation?.id); + if (index === -1 || index >= conversations.length - 1) return; + openConversation(conversations[index + 1]); + }, [conversations, activeConversation, openConversation]); + + return ( +
+
+

Conversations

+
+ + {total} conversation{total === 1 ? "" : "s"} with 2+ turns + + +
+
+ + {loading && conversations.length === 0 && ( +
+ Loading conversations... +
+ )} + + {!loading && conversations.length === 0 && ( +
+ No multi-turn conversations yet. +
+ )} + + {conversations.length > 0 && ( + <> + {/* Mobile: stacked cards — avoids the horizontal-scroll table entirely on + narrow viewports instead of squeezing 6 columns into one row. */} +
+ {conversations.map((row) => ( +
openConversation(row)} + className="rounded-xl border border-border p-3 flex flex-col gap-2 active:bg-bg-subtle cursor-pointer" + > +
+ + {row.isActive && } + { + e.stopPropagation(); + copyToClipboard(row.id); + }} + className="font-mono text-[11px] text-text-main hover:underline truncate" + > + {row.id.slice(0, 16)}… + + + + {row.turnCount} turns + +
+
+
+ {row.lastModel ?? "—"} + +
+ +
+
+ {formatTime(row.lastSeenAt)} +
+
+ ))} +
+ + {/* Desktop/tablet: full table */} +
+ + + + + + + + + + + + + {conversations.map((row) => ( + openConversation(row)} + > + + + + + + + + ))} + +
ConversationTurnsLast ModelProviderStatusLast Seen
+ + {row.isActive && } + { + e.stopPropagation(); + copyToClipboard(row.id); + }} + className="hover:underline" + > + {row.id.slice(0, 16)}… + + + + {row.turnCount} + {row.lastModel ?? "—"} + + + + + {formatTime(row.lastSeenAt)} +
+
+ + )} + + {activeConversation && ( +
+
+
e.stopPropagation()} + > +
+
+

Conversation

+ + {activeConversation.id.slice(0, 24)}… + +
+
+ + + {activeConversation.lastCallLogId && ( + + )} + + +
+
+
+ {conversationLoading ? ( +
Loading…
+ ) : ( + + )} +
+
+
+ )} + + {selectedLog && ( + + )} +
+ ); +} + +export default function ConversationsPage() { + return ( + + Loading conversations... +
+ } + > + + + ); +} diff --git a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx index 5fc31527fd..c05b5ac4ee 100644 --- a/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx +++ b/src/app/(dashboard)/dashboard/memory/components/QdrantConfigCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; @@ -47,6 +47,12 @@ export default function QdrantConfigCard() { const [embeddingOptions, setEmbeddingOptions] = useState([]); const [loading, setLoading] = useState(true); + // Generation counter for health checks. Bumping it invalidates any in-flight + // or already-resolved check so a stale result (for example one that raced a + // settings save and read the pre-save configuration) can never be applied + // out of order. + const healthSeqRef = useRef(0); + useEffect(() => { Promise.all([ fetch("/api/settings/qdrant").then((r) => (r.ok ? r.json() : null)), @@ -65,10 +71,40 @@ export default function QdrantConfigCard() { .finally(() => setLoading(false)); }, []); + const checkHealth = useCallback(async () => { + const seq = ++healthSeqRef.current; + setChecking(true); + try { + const res = await fetch("/api/settings/qdrant/health"); + if (res.ok) { + const data = await res.json(); + // Drop the result if a newer save/check invalidated this one. + if (healthSeqRef.current !== seq) return; + setHealth(data); + } else { + if (healthSeqRef.current !== seq) return; + setHealth({ ok: false, latencyMs: 0, error: "HTTP error" }); + } + } catch (e) { + if (healthSeqRef.current !== seq) return; + setHealth({ + ok: false, + latencyMs: 0, + error: e instanceof Error ? e.message : String(e), + }); + } finally { + if (healthSeqRef.current === seq) setChecking(false); + } + }, []); + const save = useCallback( async (updates: Partial & { apiKey?: string }) => { const prev = qdrant; const next = { ...qdrant, ...updates }; + // Settings are changing, so any prior health result is stale: drop it and + // invalidate in-flight checks so they cannot overwrite the new state. + healthSeqRef.current += 1; + setHealth(null); setQdrant(next); setSaving(true); setSaveStatus(""); @@ -91,6 +127,16 @@ export default function QdrantConfigCard() { setQdrant(data); setApiKeyInput(""); setSaveStatus("saved"); + // A health check started during the optimistic window (enabled just + // flipped and health was null) can race the PUT and read the OLD + // persisted settings -> not_configured/failed. Invalidate it and + // schedule a fresh check against the just-persisted settings. This + // must be explicit: if health was still null the mount effect bails + // on the setHealth(null) no-op, so a healthy Qdrant would stay red + // until a manual test. + healthSeqRef.current += 1; + setHealth(null); + void checkHealth(); setTimeout(() => setSaveStatus(""), 2000); } else { setQdrant(prev); @@ -103,25 +149,18 @@ export default function QdrantConfigCard() { setSaving(false); } }, - [qdrant] + [qdrant, checkHealth] ); - const checkHealth = useCallback(async () => { - setChecking(true); - try { - const res = await fetch("/api/settings/qdrant/health"); - if (res.ok) setHealth(await res.json()); - else setHealth({ ok: false, latencyMs: 0, error: "HTTP error" }); - } catch (e) { - setHealth({ - ok: false, - latencyMs: 0, - error: e instanceof Error ? e.message : String(e), - }); - } finally { - setChecking(false); + // Auto-check on mount once settings load: without this the status badge + // renders red after a page refresh because `health` starts as null and the + // old code treated "not checked yet" the same as "failed". The Test + // connection button still drives the same check manually. + useEffect(() => { + if (!loading && qdrant.enabled && health === null) { + void checkHealth(); } - }, []); + }, [loading, qdrant.enabled, health, checkHealth]); const runSearch = useCallback(async () => { const q = searchQuery.trim(); @@ -185,18 +224,32 @@ export default function QdrantConfigCard() {
{qdrant.enabled - ? health?.ok - ? t("qdrant.statusActive") - : t("qdrant.statusError") + ? health === null + ? t("qdrant.testing") + : health.ok + ? t("qdrant.statusActive") + : t("qdrant.statusError") : t("qdrant.statusDisabled")} diff --git a/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx b/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx index 7dacd87837..38a74a8867 100644 --- a/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx @@ -148,7 +148,11 @@ export default function MarkdownMessage({ content, className }: MarkdownMessageP }; return ( -
+ // break-words: long unspaced runs (raw JSON, ids, tokens) have no natural + // wrap point, so without it they overflow their container instead of + // wrapping — invisible in a wide full-page layout, glaring in a narrower + // one (e.g. the conversation tree modal). +
{content} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 4f9fb8388e..3ceaf1d46f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -315,13 +315,16 @@ export default function ProviderDetailPageClient() { showImportModal, importProgress, togglingAutoSync, + togglingAutoFetchModels, canImportModels, isAutoSyncEnabled, + isAutoFetchModelsEnabled, setShowImportModal, setImportProgress, handleImportModels, handleCompatibleImportWithProgress, handleToggleAutoSync, + handleToggleAutoFetchModels, } = useModelImportHandlers({ providerId, models, @@ -551,7 +554,7 @@ export default function ProviderDetailPageClient() { providerName={providerInfo?.name || providerId} /> )} - {!isUpstreamProxyProvider && !isFreeNoAuth && ( + {!isUpstreamProxyProvider && (!isFreeNoAuth || providerSupportsPat) && ( model.id)} /> )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/providerModelsSectionVisibilityKey.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/providerModelsSectionVisibilityKey.test.tsx index 76657c3e21..74b5782bb2 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/providerModelsSectionVisibilityKey.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/providerModelsSectionVisibilityKey.test.tsx @@ -102,6 +102,9 @@ function buildProps(overrides: Partial): ProviderMod isAutoSyncEnabled: false, togglingAutoSync: false, handleToggleAutoSync: vi.fn().mockResolvedValue(undefined), + isAutoFetchModelsEnabled: false, + togglingAutoFetchModels: false, + handleToggleAutoFetchModels: vi.fn().mockResolvedValue(undefined), handleCompatibleImportWithProgress: vi.fn().mockResolvedValue(undefined), compatSavingModelId: null, togglingModelId: null, @@ -161,6 +164,23 @@ afterEach(() => { }); describe("ProviderModelsSection visibility-toggle key (alias !== id)", () => { + it("shows upstream model auto-fetch for every provider with an active connection", () => { + const handleToggleAutoFetchModels = vi.fn().mockResolvedValue(undefined); + render( + buildProps({ + connections: [{ id: "connection-1", isActive: true }], + handleToggleAutoFetchModels, + }) + ); + + const toggle = Array.from(document.querySelectorAll("button")).find((button) => { + return button.textContent?.includes("Auto-fetch upstream models"); + }); + expect(toggle).toBeDefined(); + act(() => toggle?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(handleToggleAutoFetchModels).toHaveBeenCalledOnce(); + }); + it("passes the canonical providerId to the passthrough section toggles", () => { const handleToggleModelHidden = vi.fn().mockResolvedValue(undefined); const handleBulkToggleModelHidden = vi.fn().mockResolvedValue(undefined); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelImportHandlers.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelImportHandlers.test.tsx index d7228f8de7..f9909c92f9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelImportHandlers.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useModelImportHandlers.test.tsx @@ -68,11 +68,15 @@ function renderHook(params: UseModelImportHandlersParams): { get: () => HookResu }; } -function conn(id: string, active: boolean, autoSync?: boolean) { +function conn( + id: string, + active: boolean, + settings: { autoSync?: boolean; autoFetchModels?: boolean } = {} +) { return { id, isActive: active, - providerSpecificData: autoSync === undefined ? {} : { autoSync }, + providerSpecificData: settings, }; } @@ -95,17 +99,21 @@ afterEach(() => { describe("useModelImportHandlers — master autoSync", () => { it("isAutoSyncEnabled is true only when every active connection has autoSync on", () => { const mixed = renderHook( - buildParams({ connections: [conn("a", true, true), conn("b", true, false)] }) + buildParams({ connections: [conn("a", true, { autoSync: true }), conn("b", true)] }) ); expect(mixed.get().isAutoSyncEnabled).toBe(false); const allOn = renderHook( - buildParams({ connections: [conn("a", true, true), conn("b", true, true)] }) + buildParams({ + connections: [conn("a", true, { autoSync: true }), conn("b", true, { autoSync: true })], + }) ); expect(allOn.get().isAutoSyncEnabled).toBe(true); const oneOff = renderHook( - buildParams({ connections: [conn("a", true, true), conn("b", false, true)] }) + buildParams({ + connections: [conn("a", true, { autoSync: true }), conn("b", false, { autoSync: true })], + }) ); expect(oneOff.get().isAutoSyncEnabled).toBe(true); }); @@ -114,7 +122,7 @@ describe("useModelImportHandlers — master autoSync", () => { const fetchConnections = vi.fn().mockResolvedValue(undefined); const hook = renderHook( buildParams({ - connections: [conn("conn-a", true, false), conn("conn-b", true, false)], + connections: [conn("conn-a", true), conn("conn-b", true)], fetchConnections, }) ); @@ -142,7 +150,7 @@ describe("useModelImportHandlers — master autoSync", () => { it("excludes inactive connections from the fan-out", async () => { const hook = renderHook( buildParams({ - connections: [conn("conn-a", true, false), conn("conn-inactive", false, false)], + connections: [conn("conn-a", true), conn("conn-inactive", false)], }) ); const fetchMock = vi.mocked(fetch); @@ -163,7 +171,7 @@ describe("useModelImportHandlers — master autoSync", () => { it("toggling from a mixed state (one on, one off) turns all active connections on", async () => { const hook = renderHook( buildParams({ - connections: [conn("conn-a", true, true), conn("conn-b", true, false)], + connections: [conn("conn-a", true, { autoSync: true }), conn("conn-b", true)], }) ); const fetchMock = vi.mocked(fetch); @@ -196,7 +204,7 @@ describe("useModelImportHandlers — master autoSync", () => { const fetchConnections = vi.fn().mockResolvedValue(undefined); const hook = renderHook( buildParams({ - connections: [conn("conn-a", true, false), conn("conn-b", true, false)], + connections: [conn("conn-a", true), conn("conn-b", true)], fetchConnections, }) ); @@ -217,7 +225,7 @@ describe("useModelImportHandlers — master autoSync", () => { it("notifies error when every fan-out PUT fails", async () => { const hook = renderHook( buildParams({ - connections: [conn("conn-a", true, false), conn("conn-b", true, false)], + connections: [conn("conn-a", true), conn("conn-b", true)], }) ); const fetchMock = vi.mocked(fetch); @@ -233,7 +241,7 @@ describe("useModelImportHandlers — master autoSync", () => { }); it("no-ops without a PUT or notification when there are no active connections", async () => { - const hook = renderHook(buildParams({ connections: [conn("conn-a", false, false)] })); + const hook = renderHook(buildParams({ connections: [conn("conn-a", false)] })); const fetchMock = vi.mocked(fetch); await act(async () => { @@ -245,3 +253,44 @@ describe("useModelImportHandlers — master autoSync", () => { expect(notify.error).not.toHaveBeenCalled(); }); }); + +describe("useModelImportHandlers — upstream model auto-fetch", () => { + it("defaults to off until every active connection explicitly enables it", () => { + const defaultOff = renderHook(buildParams({ connections: [conn("a", true)] })); + expect(defaultOff.get().isAutoFetchModelsEnabled).toBe(false); + + const allOn = renderHook( + buildParams({ + connections: [ + conn("a", true, { autoFetchModels: true }), + conn("b", true, { autoFetchModels: true }), + ], + }) + ); + expect(allOn.get().isAutoFetchModelsEnabled).toBe(true); + }); + + it("enables auto-fetch on every active connection", async () => { + const fetchConnections = vi.fn().mockResolvedValue(undefined); + const hook = renderHook( + buildParams({ connections: [conn("conn-a", true), conn("conn-b", true)], fetchConnections }) + ); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValue({ ok: true } as Response); + + await act(async () => { + await hook.get().handleToggleAutoFetchModels(); + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + for (const [, init] of fetchMock.mock.calls) { + expect(init).toEqual( + expect.objectContaining({ + method: "PUT", + body: JSON.stringify({ providerSpecificData: { autoFetchModels: true } }), + }) + ); + } + expect(fetchConnections).toHaveBeenCalled(); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx index 5789bbbbe4..abb840e51b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx @@ -38,6 +38,7 @@ export interface CustomModelsSectionProps { copied?: string; onCopy: (text: string, key: string) => void; onModelsChanged?: () => void; + syncedModelIds?: readonly string[]; } // --------------------------------------------------------------------------- @@ -73,6 +74,7 @@ export default function CustomModelsSection({ copied, onCopy, onModelsChanged, + syncedModelIds = [], }: CustomModelsSectionProps) { const t = useTranslations("providers"); const notify = useNotificationStore(); @@ -107,6 +109,7 @@ export default function CustomModelsSection({ const customMap = useMemo(() => buildCompatMap(customModels), [customModels]); const overrideMap = useMemo(() => buildCompatMap(modelCompatOverrides), [modelCompatOverrides]); + const syncedModelIdSet = useMemo(() => new Set(syncedModelIds), [syncedModelIds]); const fetchCustomModels = useCallback(async () => { try { @@ -176,6 +179,32 @@ export default function CustomModelsSection({ } }; + const handleResetToUpstreamDefaults = async (modelId: string) => { + try { + const res = await fetch( + `/api/provider-models?provider=${encodeURIComponent(providerId)}&model=${encodeURIComponent(modelId)}&resetOverride=true`, + { method: "DELETE" } + ); + if (!res.ok) { + throw new Error("Failed to reset model override"); + } + await fetchCustomModels(); + onModelsChanged?.(); + notify.success( + providerText(t, "resetToUpstreamDefaultsSuccess", "Restored upstream model defaults") + ); + } catch (error) { + console.error("Failed to reset model override:", error); + notify.error( + providerText( + t, + "resetToUpstreamDefaultsFailed", + "Failed to restore upstream model defaults" + ) + ); + } + }; + const handleToggleHidden = async (modelId: string, hidden: boolean) => { setTogglingModelId(modelId); try { @@ -483,6 +512,7 @@ export default function CustomModelsSection({ {customModels.map((model) => { const fullModel = `${providerAlias}/${model.id}`; const copyKey = `custom-${model.id}`; + const hasSyncedBase = model.id ? syncedModelIdSet.has(model.id) : false; return (
)} + {hasSyncedBase && ( + + {providerText(t, "overridesUpstreamModel", "Overrides upstream")} + + )} {model.targetFormat && ( + {hasSyncedBase && ( + + )} + ); const autoSyncToggle = allowModelImport && compatibleSupportsModelImport && canImportModels && ( ); + const modelDiscoveryControls = ( + <> + {autoFetchModelsToggle} + {autoSyncToggle} + + ); const clearAllButton = (modelMeta.customModels.length > 0 || providerAliasEntries.length > 0) && ( - {autoSyncToggle} + {modelDiscoveryControls} {!canImportModels && ( {t("addConnectionToImport")} )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 7e74679dbe..de377cd9cb 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -435,7 +435,7 @@ export default function AddApiKeyModal({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - provider, + provider: provider === "kimi-coding" ? "kimi-coding-apikey" : provider, entries: parsed.entries.map((e) => ({ name: e.name, apiKey: e.apiKey, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx index 74db3db3c1..36064cd75a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/connModals.test.tsx @@ -241,6 +241,49 @@ describe("conn-modals (Phase 1c extraction)", () => { ); }); + it("AddApiKeyModal remaps Kimi Code bulk API-key additions to the admitted provider id", async () => { + const fetchMock = vi.fn(() => + Promise.resolve({ + ok: true, + json: async () => ({ success: 1, failed: 0, total: 1, errors: [] }), + text: async () => "", + } as Response) + ); + vi.stubGlobal("fetch", fetchMock); + const c = renderModal( + + ); + + const bulkTab = Array.from(c.querySelectorAll("button")).find( + (button) => button.textContent === "providers.bulkTabBulkAdd" + ); + act(() => bulkTab!.click()); + const bulkInput = c.querySelector("textarea"); + setTextareaValue(bulkInput!, "main|sk-kimi-test"); + const submitButton = Array.from(c.querySelectorAll("button")).find( + (button) => button.textContent === "providers.bulkAddAllKeys" + ); + await act(async () => { + submitButton!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "/api/providers/bulk", + expect.objectContaining({ + body: expect.stringContaining('"provider":"kimi-coding-apikey"'), + }) + ); + }); + it("AddApiKeyModal does not infer a regional provider selection", () => { const c = renderModal( void; setImportProgress: React.Dispatch>; handleImportModels: () => Promise; handleCompatibleImportWithProgress: (connectionId: string) => Promise; handleToggleAutoSync: () => Promise; + handleToggleAutoFetchModels: () => Promise; } // ──── hook ─────────────────────────────────────────────────────────────────── @@ -99,6 +102,7 @@ export function useModelImportHandlers({ importedCount: 0, }); const [togglingAutoSync, setTogglingAutoSync] = useState(false); + const [togglingAutoFetchModels, setTogglingAutoFetchModels] = useState(false); // Derived const canImportModels = isFreeNoAuth || connections.some((conn) => conn.isActive !== false); @@ -109,6 +113,11 @@ export function useModelImportHandlers({ const isAutoSyncEnabled = activeConnections.length > 0 && activeConnections.every((conn) => !!conn.providerSpecificData?.autoSync); + // Discovery persists its response in the synced-model cache, so opt in on every + // active connection before treating the provider-level control as enabled. + const isAutoFetchModelsEnabled = + activeConnections.length > 0 && + activeConnections.every((conn) => conn.providerSpecificData?.autoFetchModels === true); const handleImportModels = async () => { if (importingModels) return; @@ -422,17 +431,79 @@ export function useModelImportHandlers({ } }; + const handleToggleAutoFetchModels = async () => { + if (togglingAutoFetchModels) return; + const activeWithId = activeConnections.filter((conn) => conn.id); + if (activeWithId.length === 0) return; + + setTogglingAutoFetchModels(true); + try { + const newValue = !isAutoFetchModelsEnabled; + const results = await Promise.allSettled( + activeWithId.map((conn) => + fetch(`/api/providers/${conn.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerSpecificData: { + ...(conn.providerSpecificData || {}), + autoFetchModels: newValue, + }, + }), + }) + ) + ); + await fetchConnections(); + const fulfilled = results.filter((result) => { + return result.status === "fulfilled" && result.value.ok; + }).length; + if (fulfilled === results.length) { + notify[newValue ? "success" : "info"]( + newValue + ? providerText(t, "autoFetchModelsEnabled", "Upstream model auto-fetch enabled") + : providerText(t, "autoFetchModelsDisabled", "Upstream model auto-fetch disabled") + ); + } else if (fulfilled === 0) { + notify.error( + providerText( + t, + "autoFetchModelsToggleFailed", + "Failed to toggle upstream model auto-fetch" + ) + ); + } else { + notify.warning( + providerText( + t, + "autoFetchModelsPartialFailure", + "Some connections updated, but upstream model auto-fetch was not changed everywhere" + ) + ); + } + } catch (error) { + console.error("Error toggling upstream model auto-fetch:", error); + notify.error( + providerText(t, "autoFetchModelsToggleFailed", "Failed to toggle upstream model auto-fetch") + ); + } finally { + setTogglingAutoFetchModels(false); + } + }; + return { importingModels, showImportModal, importProgress, togglingAutoSync, + togglingAutoFetchModels, canImportModels, isAutoSyncEnabled, + isAutoFetchModelsEnabled, setShowImportModal, setImportProgress, handleImportModels, handleCompatibleImportWithProgress, handleToggleAutoSync, + handleToggleAutoFetchModels, }; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index bce408d4f9..fc52c914ad 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -248,7 +248,7 @@ export const CONFIGURABLE_BASE_URL_PROVIDERS = new Set([ export const DEFAULT_PROVIDER_BASE_URLS: Record = { "azure-openai": "https://example-resource.openai.azure.com", "azure-ai": "https://example-resource.services.ai.azure.com/openai/v1", - "bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + "bailian-coding-plan": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", "xiaomi-mimo": "https://token-plan-sgp.xiaomimimo.com/v1", siliconflow: "https://api.siliconflow.com/v1", "searxng-search": "http://localhost:8888/search", diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx index 83a504bd14..a63160829a 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx @@ -1,12 +1,13 @@ "use client"; -import type { MouseEvent, ReactNode } from "react"; +import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Badge, Card, Toggle } from "@/shared/components"; +import { Badge, Card, Modal, Toggle } from "@/shared/components"; import ProviderTestSlideOver from "@/shared/components/ProviderTestSlideOver"; import ProviderIcon from "@/shared/components/ProviderIcon"; import { @@ -24,6 +25,12 @@ interface ProviderStats { connected?: number; error?: number; warning?: number; + /** Highest failure count among currently "warning" api keys (#10261 — sanitized, + * never the raw upstream error text). */ + warningMaxFailures?: number; + /** Pre-formatted relative time (e.g. "3h ago") of the most recent warning-key + * failure, computed by the caller via `getRelativeTime()` (#10261). */ + warningLastFailureRelative?: string | null; errorCode?: string | null; errorTime?: string | null; allDisabled?: boolean; @@ -65,6 +72,9 @@ interface ProviderCardProps { hasFree?: boolean; freeNote?: string; subscriptionRisk?: boolean; + /** Which risk copy variant to show in the details dialog (#10261). Falls back + * to "oauth" when absent — mirrors `ProviderModalsPanel`'s default. */ + riskNoticeVariant?: "oauth" | "webCookie" | "deprecated" | "embedded-service"; /** Declared service kinds — "llm" enables the inline Test button */ serviceKinds?: string[]; /** Optional operator-supplied remote icon URL (#2166) for compatible provider nodes. */ @@ -114,13 +124,79 @@ function providerText( return fallback; } +interface WarningBadgeDetails { + warningMaxFailures: number; + warningLastFailureRelative: string | null; + onActivate: () => void; +} + +/** #10261 — the warning-count badge previously had no `title` (no reasons exposed) + * and no click affordance, even though the reasons already exist in + * `providerSpecificData.apiKeyHealth[]`. Renders a keyboard- and pointer- + * interactive wrapper around the Badge that exposes a sanitized reasons summary + * (never raw upstream error text — Hard Rule #12) and navigates to the + * connection-health view on activation. */ +function WarningBadge({ + count, + t, + details, +}: { + count: number; + t: ReturnType; + details: WarningBadgeDetails; +}) { + const lastFailureSuffix = details.warningLastFailureRelative + ? providerText(t, "warningNotice.lastFailureSuffix", " (last failure {time})", { + time: details.warningLastFailureRelative, + }) + : ""; + const tooltip = providerText( + t, + "warningNotice.tooltip", + "{count} connection(s) flagged — up to {maxFailures} recent failures{lastFailureSuffix}. Click to view connection health.", + { + count, + maxFailures: details.warningMaxFailures, + lastFailureSuffix, + } + ); + const ariaLabel = providerText(t, "warningNotice.ariaLabel", "View connection health details, {count} warning(s)", { + count, + }); + + const activate = (e: MouseEvent | KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + details.onActivate(); + }; + + return ( + { + if (e.key === "Enter" || e.key === " ") activate(e); + }} + className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-primary/50" + > + + {t("warningCount", { count })} + + + ); +} + function getStatusDisplay( connected: number, error: number, warning: number, errorCode: string | null | undefined, t: ReturnType, - afterConnected?: ReactNode + afterConnected?: ReactNode, + warningDetails?: WarningBadgeDetails ) { const parts: ReactNode[] = []; if (connected > 0) { @@ -133,9 +209,13 @@ function getStatusDisplay( } if (warning > 0) { parts.push( - - {t("warningCount", { count: warning })} - + warningDetails ? ( + + ) : ( + + {t("warningCount", { count: warning })} + + ) ); } if (error > 0) { @@ -167,11 +247,13 @@ const ProviderCard = forwardRef(function const t = useTranslations("providers"); const tc = useTranslations("common"); const tp = useTranslations("miniPlayground"); + const router = useRouter(); const kindLabel = (kind: string) => { const entry = KIND_LABEL_KEYS[kind]; return entry ? providerText(t, entry.key, entry.fallback) : kind; }; const [testExpanded, setTestExpanded] = useState(false); + const [riskDetailsOpen, setRiskDetailsOpen] = useState(false); const innerRef = useRef(null); const linkElementRef = useRef(null); @@ -222,6 +304,14 @@ const ProviderCard = forwardRef(function e.stopPropagation(); setTestExpanded((v) => !v); }; + const handleRiskIndicatorActivate = (e: MouseEvent | KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + setRiskDetailsOpen(true); + }; + const handleWarningBadgeActivate = useCallback(() => { + router.push(`/dashboard/providers/${providerId}`); + }, [router, providerId]); const connected = Number(stats.connected || 0); const error = Number(stats.error || 0); const allDisabled = Boolean(stats.allDisabled); @@ -434,13 +524,19 @@ const ProviderCard = forwardRef(function )} {provider.subscriptionRisk === true && ( - { + if (e.key === "Enter" || e.key === " ") handleRiskIndicatorActivate(e); + }} > info - + )} (function Number(stats.warning || 0), stats.errorCode, t, - codexServiceTierChip + codexServiceTierChip, + Number(stats.warning || 0) > 0 + ? { + warningMaxFailures: Number(stats.warningMaxFailures || 0), + warningLastFailureRelative: stats.warningLastFailureRelative ?? null, + onActivate: handleWarningBadgeActivate, + } + : undefined )} {stats.expiryStatus === "expired" && ( @@ -570,6 +673,26 @@ const ProviderCard = forwardRef(function staticIconPath={staticIconPath} /> )} + {provider.subscriptionRisk === true && ( + setRiskDetailsOpen(false)} + title={providerText(t, "riskNotice.detailsTitle", "Usage caveats")} + size="sm" + > +
+ +

+ {t(`riskNotice.${provider.riskNoticeVariant ?? "oauth"}`)} +

+
+
+ )}
); }); diff --git a/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts b/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts index 61f540b3e4..17dd089e12 100644 --- a/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts +++ b/src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts @@ -69,14 +69,19 @@ export function useProviderModels(providerId: string): UseProviderModelsResult { const connRes = await fetch("/api/providers"); if (!connRes.ok || cancelled) return; const connData = (await connRes.json()) as { - connections?: Array<{ id: string; provider: string; isActive?: boolean }>; + connections?: Array<{ + id: string; + provider: string; + isActive?: boolean; + providerSpecificData?: { autoFetchModels?: boolean }; + }>; }; if (cancelled) return; const providerConn = connData.connections?.find( (c) => (c.provider === providerId || c.id === providerId) && c.isActive !== false ); - if (providerConn && !cancelled) { + if (providerConn?.providerSpecificData?.autoFetchModels === true && !cancelled) { const syncRes = await fetch( `/api/providers/${encodeURIComponent(providerConn.id)}/sync-models?mode=sync`, { method: "POST" } diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 4402b83404..6c853573b9 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -393,18 +393,39 @@ export default function ProvidersPage() { : null : null; - // Count API keys in "warning" state across all connections + // Count API keys in "warning" state across all connections, and (#10261) + // aggregate a SANITIZED reasons summary (max failure count + most recent + // failure time — never the raw upstream error text) so the warning badge + // can expose why connections are flagged instead of a bare count. + let warningMaxFailures = 0; + let warningLatestFailureAt: string | null = null; const warning = providerConnections.reduce((warnCount, conn) => { const health = (conn as any).providerSpecificData?.apiKeyHealth as - Record | undefined; + | Record + | undefined; if (!health) return warnCount; - return warnCount + Object.values(health).filter((h) => h.status === "warning").length; + const warningEntries = Object.values(health).filter((h) => h.status === "warning"); + for (const entry of warningEntries) { + warningMaxFailures = Math.max(warningMaxFailures, entry.failures ?? 0); + if ( + entry.lastFailure && + (!warningLatestFailureAt || entry.lastFailure > warningLatestFailureAt) + ) { + warningLatestFailureAt = entry.lastFailure; + } + } + return warnCount + warningEntries.length; }, 0); + const warningLastFailureRelative = warningLatestFailureAt + ? getRelativeTime(warningLatestFailureAt) + : null; return { connected, error, warning, + warningMaxFailures, + warningLastFailureRelative, total, errorCode, errorTime, diff --git a/src/app/(dashboard)/dashboard/settings/components/AutoDisableCard.tsx b/src/app/(dashboard)/dashboard/settings/components/AutoDisableCard.tsx index fb75847983..a3d3dc4f2e 100644 --- a/src/app/(dashboard)/dashboard/settings/components/AutoDisableCard.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/AutoDisableCard.tsx @@ -4,10 +4,37 @@ import { useState, useEffect } from "react"; import { Card, Button, Input } from "@/shared/components"; import { useTranslations } from "next-intl"; import { useNotificationStore } from "@/store/notificationStore"; +import { + AUTO_DISABLE_BANNED_SCOPES, + normalizeAutoDisableBannedScope, + type AutoDisableBannedScope, +} from "@/shared/utils/autoDisableBanned"; + +type AutoDisableForm = { + enabled: boolean; + threshold: number; + scope: AutoDisableBannedScope; +}; + +function normalizeForm(json: Partial | null | undefined): AutoDisableForm { + return { + enabled: Boolean(json?.enabled), + threshold: typeof json?.threshold === "number" ? json.threshold : 3, + scope: normalizeAutoDisableBannedScope(json?.scope), + }; +} export default function AutoDisableCard() { - const [data, setData] = useState({ enabled: false, threshold: 3 }); - const [draft, setDraft] = useState({ enabled: false, threshold: 3 }); + const [data, setData] = useState({ + enabled: false, + threshold: 3, + scope: "all", + }); + const [draft, setDraft] = useState({ + enabled: false, + threshold: 3, + scope: "all", + }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [editMode, setEditMode] = useState(false); @@ -19,8 +46,9 @@ export default function AutoDisableCard() { fetch("/api/settings/auto-disable-accounts") .then((res) => res.json()) .then((json) => { - setData(json); - setDraft(json); + const next = normalizeForm(json); + setData(next); + setDraft(next); setLoading(false); }) .catch(() => setLoading(false)); @@ -35,7 +63,7 @@ export default function AutoDisableCard() { body: JSON.stringify(draft), }); if (!res.ok) throw new Error("Failed to save auto-disable config"); - const savedData = await res.json(); + const savedData = normalizeForm(await res.json()); setData(savedData); setEditMode(false); notify.success(t("savedSuccessfully")); @@ -48,6 +76,9 @@ export default function AutoDisableCard() { if (loading) return null; + const current = editMode ? draft : data; + const scopes = AUTO_DISABLE_BANNED_SCOPES; + return (
@@ -94,7 +125,7 @@ export default function AutoDisableCard() {
+ +
+

+ {t("autoDisableBannedScope")} +

+

{t("autoDisableBannedScopeDesc")}

+
+ {scopes.map((scope) => ( + + ))} +
+
); diff --git a/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVideoTab.tsx b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVideoTab.tsx index d7cd0c7857..e12ceb5789 100644 --- a/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVideoTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeVideoTab.tsx @@ -10,6 +10,7 @@ import { VIDEO_BRIDGE_TIMEOUT_MAX_MS, VIDEO_BRIDGE_TIMEOUT_MIN_MS, resolveVideoBridgeRuntimeSettings, + type VideoSamplingPolicy, } from "@/shared/constants/modalityBridgeDefaults"; import ModalityBridgeStatsRow from "./ModalityBridgeStatsRow"; @@ -18,6 +19,7 @@ interface VideoState { modalityBridgeVideoEnabled: boolean; modalityBridgeVideoModel: string; modalityBridgeVideoFrameCount: number; + modalityBridgeVideoSamplingPolicy: VideoSamplingPolicy; modalityBridgeVideoMaxVideos: number; modalityBridgeVideoTimeout: number; } @@ -44,6 +46,7 @@ function fromApi(value: unknown): VideoState { modalityBridgeVideoEnabled: runtime.enabled, modalityBridgeVideoModel: runtime.model, modalityBridgeVideoFrameCount: runtime.frameCount, + modalityBridgeVideoSamplingPolicy: runtime.samplingPolicy, modalityBridgeVideoMaxVideos: runtime.maxVideos, modalityBridgeVideoTimeout: runtime.timeoutMs, }; @@ -286,6 +289,24 @@ export default function ModalityBridgeVideoTab({ ) } /> + diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx index 496cadd38a..fbc67c8b27 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble.tsx @@ -4,10 +4,18 @@ import { useState } from "react"; import { useTranslations } from "next-intl"; import type { NormalizedTurn } from "@/mitm/inspector/types"; import { cn } from "@/shared/utils/cn"; +import { formatTime } from "@/shared/utils/formatting"; import { MessageContent } from "./MessageContent"; interface ChatBubbleProps { turn: NormalizedTurn; + /** Optional — makes the bubble clickable when a caller has somewhere to + * navigate to for this turn (e.g. a tree/list view linking back to the + * request that produced it). */ + onClick?: () => void; + /** True when this turn belongs to the request currently open — shown + * highlighted instead of clickable (nowhere further to navigate to). */ + isCurrent?: boolean; } const ROLE_STYLES: Record = { @@ -24,27 +32,41 @@ const ROLE_LABEL_KEY: Record = { tool: "roleTool", }; -export function ChatBubble({ turn }: ChatBubbleProps) { +export function ChatBubble({ turn, onClick, isCurrent }: ChatBubbleProps) { const t = useTranslations("trafficInspector"); const [collapsed, setCollapsed] = useState(turn.role === "system"); const isSystem = turn.role === "system"; const isUser = turn.role === "user"; + const clickable = Boolean(onClick) && !isCurrent; return (
- {t(ROLE_LABEL_KEY[turn.role])} +
+ {t(ROLE_LABEL_KEY[turn.role])} + {turn.timestamp && ( + {formatTime(turn.timestamp)} + )} +
{isSystem && ( + )} +
+ +
+ {open && ( +
+          {json}
+        
+ )} + + ); +} + +// ─── Conversation context section ─────────────────────────────────────────── +// Renders THIS request's own context (its request body's messages/input, plus +// its response) — a plain single-request normalization, same shape as the +// traffic-inspector's ConversationTab, no cross-request reconstruction. While +// the request is still generating (detail.active === true) the response side +// shows the partial text captured so far, refreshed on a short poll scoped to +// just this section. +const CONVERSATION_ACTIVE_POLL_INTERVAL_MS = 1200; + +function asInterceptedResponseBody(responseBody: unknown): InterceptedRequest { + return { + id: "", + source: "custom-host", + timestamp: "", + method: "POST", + host: "", + path: "", + requestHeaders: {}, + requestBody: null, + requestSize: 0, + responseHeaders: {}, + responseBody: responseBody != null ? JSON.stringify(responseBody) : null, + responseSize: 0, + status: 0, + detectedKind: "llm", + }; +} + +export function ConversationContextSection({ log, detail }) { + const [open, setOpen] = useState(true); + const [liveDetail, setLiveDetail] = useState(detail); + const [liveRefresh, setLiveRefresh] = useState(() => { + try { + const v = localStorage.getItem("pref:conversationContext:liveRefresh"); + return v == null ? true : v === "1"; + } catch { + return true; + } + }); + const turnsBoxRef = useRef(null); + + useEffect(() => { + setLiveDetail(detail); + }, [detail]); + + // Same live-poll pattern as the SSE Events section (StreamSection below), + // but gated on liveRefresh too: an active request keeps generating either + // way, this toggle only controls whether THIS panel keeps fetching/ + // redrawing while the user reads it. + useEffect(() => { + if (!liveDetail?.active || !liveRefresh) return; + let cancelled = false; + let timeoutId: ReturnType | undefined; + + const tick = () => { + if (cancelled) return; + if (document.visibilityState !== "visible") { + timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS); + return; + } + fetch(`/api/logs/${log.id}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (cancelled || !data) return; + setLiveDetail(data); + if (data.active) timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS); + }) + .catch(() => { + timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS); + }); + }; + + timeoutId = setTimeout(tick, CONVERSATION_ACTIVE_POLL_INTERVAL_MS); + return () => { + cancelled = true; + if (timeoutId) clearTimeout(timeoutId); + }; + }, [liveDetail?.active, liveRefresh, log.id]); + + const toggleLiveRefresh = () => { + const next = !liveRefresh; + setLiveRefresh(next); + try { + localStorage.setItem("pref:conversationContext:liveRefresh", next ? "1" : "0"); + } catch {} + }; + + const scrollToBottom = () => { + const el = turnsBoxRef.current; + if (!el) return; + requestAnimationFrame(() => { + try { + el.scrollTop = el.scrollHeight; + } catch {} + }); + }; + + const requestBody = + liveDetail?.requestBody ?? liveDetail?.pipelinePayloads?.clientRequest ?? null; + const requestTurns = buildRequestTurns(requestBody) ?? []; + + const responseBody = liveDetail?.responseBody ?? null; + const responseTurns: NormalizedTurn[] = + responseBody != null + ? buildResponseTurns(asInterceptedResponseBody(responseBody)) + : liveDetail?.partialAssistantText + ? [ + { + role: "assistant", + blocks: [{ type: "text", text: liveDetail.partialAssistantText }], + }, + ] + : []; + + const allTurns: NormalizedTurn[] = [...requestTurns, ...responseTurns]; + + // Follow new content as it streams in — same idea as StreamSection's + // autoscroll effect, tied to the same liveRefresh toggle. + useEffect(() => { + if (!liveRefresh || !open) return; + scrollToBottom(); + }, [allTurns.length, liveDetail?.partialAssistantText, liveRefresh, open]); + + if (allTurns.length === 0) return null; + + return ( +
+
+
+

+ Conversation Context +

+ +
+ {open && ( +
+ {liveDetail?.active && ( + + )} + +
+ )} +
+ {open && ( +
+ {allTurns.map((turn, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index 6938e1da2f..02107bdf22 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -9,60 +9,10 @@ import { } from "@/shared/constants/colors"; import { formatDuration, formatApiKeyLabel, maskAccount } from "@/shared/utils/formatting"; import { formatErrorForDisplay } from "@/shared/utils/formatting"; - -// ─── Payload Code Block ───────────────────────────────────────────────────── - -function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen = true }) { - const t = useTranslations("requestLogger.detail"); - const [copied, setCopied] = useState(false); - const [open, setOpen] = useState(defaultOpen); - - const handleCopy = async () => { - const success = await onCopy(); - if (success !== false) { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - - return ( -
-
-
-

- {title} -

- {collapsible && ( - - )} -
- -
- {open && ( -
-          {json}
-        
- )} -
- ); -} +import { + PayloadSection, + ConversationContextSection, +} from "@/shared/components/RequestLoggerDetail.sections"; // ─── Stream section + Detail Modal ─────────────────────────────────────────────────────────── @@ -354,7 +304,7 @@ export default function RequestLoggerDetail({ const codexAccountRotation = getCodexAccountRotation(detail); return (
e.stopPropagation()} > {/* Modal Header */} -
-
+
+
{log.active ? ( @@ -414,23 +364,31 @@ export default function RequestLoggerDetail({ )}
-
- - +
+ {/* Only rendered when a caller actually wires up navigation (RequestLoggerV2's + list view) — a caller with no ordered-list context to navigate through + (conversations page, RequestTimeline) passes neither, so there's nothing + to show instead of a permanently-disabled dead button. */} + {(onPrevious || onNext) && ( + <> + + + + )}
-
+
{/* Metadata Grid */} {log.active ? (
@@ -868,6 +826,8 @@ export default function RequestLoggerDetail({
) : ( <> + + {streamChunks && streamChunks.provider && ( (null); const [visibleColumns, setVisibleColumns] = useState(() => { const defaultVisible = Object.fromEntries(columns.map((c) => [c.key, true])); @@ -750,9 +757,14 @@ const RequestLoggerV2 = forwardRef { const idx = currentLogIndex; @@ -764,10 +776,44 @@ const RequestLoggerV2 = forwardRef { console.error("Failed to open previous log id:", error_); }); + } else { + pendingBoundaryNavRef.current = "next"; + fetchLogs(false); + } + }, [currentLogIndex, sortedLogsForNav, fetchLogs]); + + // Resolves a pending boundary nav (see handlePrev/handleNext) once a + // triggered fetchLogs() resync has landed in sortedLogsForNav. Only fires + // when a boundary nav is actually pending, so this is a no-op on the + // normal (paused-while-modal-open) list-update cadence. + useEffect(() => { + const direction = pendingBoundaryNavRef.current; + if (!direction || !selectedLog) return; + pendingBoundaryNavRef.current = null; + const idx = sortedLogsForNav.findIndex((l) => l.id === selectedLog.id); + const target = + direction === "prev" + ? idx > 0 + ? sortedLogsForNav[idx - 1] + : null + : idx >= 0 && idx < sortedLogsForNav.length - 1 + ? sortedLogsForNav[idx + 1] + : null; + if (target?.id) { + openDetail(target) + .then((r) => r) + .catch((error_) => { + console.error("Failed to open adjacent log id:", error_); + }); } else { closeDetail(); } - }, [currentLogIndex, sortedLogsForNav]); + // openDetail/closeDetail are plain functions re-created every render + // (same as handlePrev/handleNext above and the rest of this file) — + // listing them would re-fire this effect on every render instead of + // only when sortedLogsForNav/selectedLog actually change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sortedLogsForNav, selectedLog]); const toggleDetailLogging = async () => { setDetailLoggingLoading(true); @@ -1241,6 +1287,9 @@ const RequestLoggerV2 = forwardRef )} + {visibleColumns.conversation && ( + {t("columns.conversation")} + )} @@ -1588,6 +1637,15 @@ const RequestLoggerV2 = forwardRef )} + {visibleColumns.conversation && ( + + {log.sessionTag ? ( + {log.sessionTag.slice(0, 12)}… + ) : ( + + )} + + )} ); })} diff --git a/src/shared/components/RequestTimeline.tsx b/src/shared/components/RequestTimeline.tsx index cd5acd39df..af4a061d09 100644 --- a/src/shared/components/RequestTimeline.tsx +++ b/src/shared/components/RequestTimeline.tsx @@ -3,134 +3,35 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { getHttpStatusStyle } from "@/shared/constants/colors"; import { copyToClipboard } from "@/shared/utils/clipboard"; import RequestLoggerDetail from "@/shared/components/RequestLoggerDetail"; +import useEmailPrivacyStore from "@/store/emailPrivacyStore"; +import { + type TimelineLog, + type ViewMode, + VISIBLE_WINDOW_MS, + BAR_HEIGHT, + LANE_GAP, + LANE_HEIGHT, + HEADER_HEIGHT, + AXIS_HEIGHT, + MIN_BAR_WIDTH, + DEFAULT_LIST_POLL_SECONDS, + TIMELINE_LIST_POLL_STORAGE_KEY, + FOLLOW_LINE_X, + LIVE_LINE_FRACTION, + computeBarRange, + MODE_META, + formatTimeAxis, + getStatusColor, + CONVERSATION_LANE_REUSE_STORAGE_KEY, + allocateLanes, + truncateModel, + formatDateLabel, +} from "@/shared/components/RequestTimeline.utils"; -interface TimelineLog { - id: string; - timestamp: string; - status: number; - model: string | null; - provider: string | null; - account: string | null; - duration: number; - tokens: { in: number; out: number }; - active?: boolean; - completed?: boolean; - error?: string | null; - path?: string | null; -} - -interface Lane { - startMs: number; - endMs: number; -} - -type ViewMode = "follow" | "live" | "pan"; - -const VISIBLE_WINDOW_MS = 5 * 60 * 1000; -const BAR_HEIGHT = 28; -const LANE_GAP = 4; -const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP; -const HEADER_HEIGHT = 48; -const AXIS_HEIGHT = 32; -const MIN_BAR_WIDTH = 3; -const POLL_INTERVAL_MS = 2000; -const FOLLOW_LINE_X = 0.75; -const LIVE_LINE_FRACTION = 0.9; - -function computeBarRange(log: TimelineLog, nowMs: number): { startMs: number; endMs: number } { - const ts = new Date(log.timestamp).getTime(); - if (log.active) return { startMs: ts, endMs: nowMs }; - if (log.completed) return { startMs: ts, endMs: ts + (log.duration || 0) }; - return { startMs: ts - (log.duration || 0), endMs: ts }; -} - -const MODE_META: Record = { - follow: { - labelKey: "follow", - descriptionKey: "followDescription", - }, - live: { - labelKey: "now", - descriptionKey: "nowDescription", - }, - pan: { - labelKey: "pan", - descriptionKey: "panDescription", - }, -}; - -function formatTimeAxis(ms: number): string { - const d = new Date(ms); - const h = d.getHours().toString().padStart(2, "0"); - const m = d.getMinutes().toString().padStart(2, "0"); - const s = d.getSeconds().toString().padStart(2, "0"); - return `${h}:${m}:${s}`; -} - -function getStatusColor(status: number, active: boolean | undefined): string { - if (active) return "#6366F1"; - return getHttpStatusStyle(status).bg; -} - -function allocateLanes(items: TimelineLog[], nowMs: number): Map { - const lanes: Lane[] = []; - const laneMap = new Map(); - - const sorted = [...items].sort((a, b) => { - const aStart = new Date(a.timestamp).getTime(); - const bStart = new Date(b.timestamp).getTime(); - return aStart - bStart; - }); - - for (const item of sorted) { - const { startMs, endMs } = computeBarRange(item, nowMs); - - let placed = false; - for (let i = 0; i < lanes.length; i++) { - if (lanes[i].endMs < startMs) { - lanes[i] = { startMs, endMs }; - laneMap.set(item.id, i); - placed = true; - break; - } - } - if (!placed) { - laneMap.set(item.id, lanes.length); - lanes.push({ startMs, endMs }); - } - } - - return laneMap; -} - -function truncateModel(model: string | null): string { - if (!model) return ""; - const parts = model.split("/"); - const short = parts[parts.length - 1]; - return short.length > 16 ? short.slice(0, 15) + "\u2026" : short; -} - -function formatDateLabel(ms: number): string { - const d = new Date(ms); - const months = [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ]; - return `${months[d.getMonth()]} ${d.getDate()}`; -} +export type { TimelineLog } from "@/shared/components/RequestTimeline.utils"; +export { allocateLanes } from "@/shared/components/RequestTimeline.utils"; export default function RequestTimeline({ initialSelectedId, @@ -152,9 +53,31 @@ export default function RequestTimeline({ const [isDragging, setIsDragging] = useState(false); const [dragStartX, setDragStartX] = useState(0); const [dragStartOffset, setDragStartOffset] = useState(0); + const { emailsVisible } = useEmailPrivacyStore(); const [selectedLog, setSelectedLog] = useState(null); const [detailData, setDetailData] = useState(null); const [detailLoading, setDetailLoading] = useState(false); + const [detailLoggingEnabled, setDetailLoggingEnabled] = useState(false); + const [conversationLaneReuseMinutes, setConversationLaneReuseMinutes] = useState(() => { + if (globalThis.window === undefined) return 2; + try { + const saved = localStorage.getItem(CONVERSATION_LANE_REUSE_STORAGE_KEY); + const parsed = saved ? Number(saved) : 2; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 2; + } catch { + return 2; + } + }); + const [listPollSeconds, setListPollSeconds] = useState(() => { + if (globalThis.window === undefined) return DEFAULT_LIST_POLL_SECONDS; + try { + const saved = localStorage.getItem(TIMELINE_LIST_POLL_STORAGE_KEY); + const parsed = saved ? Number(saved) : DEFAULT_LIST_POLL_SECONDS; + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_LIST_POLL_SECONDS; + } catch { + return DEFAULT_LIST_POLL_SECONDS; + } + }); const canvasRef = useRef(null); const animRef = useRef(0); // Guards the ?id= deep-link mount effect below. Also armed by any manual @@ -164,6 +87,16 @@ export default function RequestTimeline({ // reopen the modal right after the user closed it. const initialOpenedRef = useRef(false); + useEffect(() => { + fetch("/api/logs/detail?limit=1") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (!data) return; + setDetailLoggingEnabled(data.enabled === true); + }) + .catch(() => {}); + }, []); + useEffect(() => { let cancelled = false; fetch("/api/usage/call-logs?limit=200") @@ -181,12 +114,12 @@ export default function RequestTimeline({ .then((res) => (res.ok ? res.json() : [])) .then((data) => setLogs(data)) .catch(() => {}); - }, POLL_INTERVAL_MS); + }, listPollSeconds * 1000); return () => { cancelled = true; clearInterval(id); }; - }, []); + }, [listPollSeconds]); useEffect(() => { if (!canvasRef.current) return undefined; @@ -252,7 +185,10 @@ export default function RequestTimeline({ }); }, [logs, timeStart, timeEnd, nowMs]); - const laneMap = useMemo(() => allocateLanes(logs, nowMs), [logs, nowMs]); + const laneMap = useMemo( + () => allocateLanes(logs, nowMs, conversationLaneReuseMinutes * 60 * 1000), + [logs, nowMs, conversationLaneReuseMinutes] + ); const maxLane = useMemo(() => (laneMap.size > 0 ? Math.max(...laneMap.values()) : 0), [laneMap]); const barElements = useMemo(() => { @@ -273,6 +209,39 @@ export default function RequestTimeline({ }); }, [visibleLogs, timeStart, timeEnd, nowMs, laneMap, canvasWidth]); + // One connector per consecutive pair of bars sharing a conversation id AND + // lane (i.e. allocateLanes actually treated them as one continuous + // conversation, not two bars that just happen to be adjacent). + const connectorElements = useMemo(() => { + const byConversation = new Map(); + for (const el of barElements) { + const cid = el.log.sessionTag; + if (!cid) continue; + const list = byConversation.get(cid); + if (list) list.push(el); + else byConversation.set(cid, [el]); + } + + const connectors: { id: string; x1: number; x2: number; y: number }[] = []; + for (const els of byConversation.values()) { + const sorted = [...els].sort( + (a, b) => new Date(a.log.timestamp).getTime() - new Date(b.log.timestamp).getTime() + ); + for (let i = 0; i < sorted.length - 1; i++) { + const a = sorted[i]; + const b = sorted[i + 1]; + if (a.topPx !== b.topPx) continue; // different lanes — reuse window lapsed + connectors.push({ + id: `${a.log.id}-${b.log.id}`, + x1: a.leftPct + a.widthPct, + x2: b.leftPct, + y: a.topPx + BAR_HEIGHT / 2, + }); + } + } + return connectors; + }, [barElements]); + const axisTicks = useMemo(() => { const totalMs = timeEnd - timeStart; if (totalMs <= 0) return []; @@ -374,33 +343,43 @@ export default function RequestTimeline({ // Deep-link support: open the request from ?id= on mount without waiting for // it to show up in the polled `logs` list (mirrors RequestLoggerV2's openDetail). - const openById = useCallback(async (id: string) => { - setDetailLoading(true); - try { - const res = await fetch(`/api/logs/${id}`, { cache: "no-store" }); - const data = res.ok ? await res.json() : null; - if (data) { - setSelectedLog({ - id: data.id ?? id, - timestamp: data.timestamp, - status: data.status ?? 0, - model: data.model ?? null, - provider: data.provider ?? null, - account: data.account ?? null, - duration: data.duration ?? 0, - tokens: data.tokens ?? { in: 0, out: 0 }, - active: data.active, - error: data.error ?? null, - path: data.path ?? null, - }); - setDetailData(data); + const openById = useCallback( + async (id: string) => { + try { + const url = new URL(globalThis.location.href); + url.searchParams.set("id", id); + router.replace(url.pathname + url.search); + } catch { + // ignore navigation errors } - } catch { - // ignore fetch errors - } finally { - setDetailLoading(false); - } - }, []); + setDetailLoading(true); + try { + const res = await fetch(`/api/logs/${id}`, { cache: "no-store" }); + const data = res.ok ? await res.json() : null; + if (data) { + setSelectedLog({ + id: data.id ?? id, + timestamp: data.timestamp, + status: data.status ?? 0, + model: data.model ?? null, + provider: data.provider ?? null, + account: data.account ?? null, + duration: data.duration ?? 0, + tokens: data.tokens ?? { in: 0, out: 0 }, + active: data.active, + error: data.error ?? null, + path: data.path ?? null, + }); + setDetailData(data); + } + } catch { + // ignore fetch errors + } finally { + setDetailLoading(false); + } + }, + [router] + ); useEffect(() => { if (!initialSelectedId || initialOpenedRef.current) return; @@ -576,6 +555,51 @@ export default function RequestTimeline({ > {t("reset")} + {/* Conversation lane-reuse window: how long a lane stays reserved + for its conversation before falling back to normal packing. */} + + {/* How often the timeline re-polls /api/usage/call-logs for new rows. */} + {/* Zoom */}
))} + + {/* Conversation connectors — one arrow per consecutive same-conversation + bar pair sharing a lane. */} + + + + + + + {connectorElements.map(({ id, x1, x2, y }) => ( + + ))} +
{/* NOW line — full height of the canvas, outside content div */} @@ -828,8 +893,8 @@ export default function RequestTimeline({ log={selectedLog as any} detail={detailData} loading={detailLoading} - debugEnabled={false} - emailsVisible={false} + debugEnabled={selectedLog?.active ? true : detailLoggingEnabled} + emailsVisible={emailsVisible} onClose={closeDetail} onCopy={copyToClipboard} onPrevious={undefined} diff --git a/src/shared/components/RequestTimeline.utils.ts b/src/shared/components/RequestTimeline.utils.ts new file mode 100644 index 0000000000..c944ea9cec --- /dev/null +++ b/src/shared/components/RequestTimeline.utils.ts @@ -0,0 +1,169 @@ +import { getHttpStatusStyle } from "@/shared/constants/colors"; + +export interface TimelineLog { + id: string; + timestamp: string; + status: number; + model: string | null; + provider: string | null; + account: string | null; + duration: number; + tokens: { in: number; out: number }; + active?: boolean; + completed?: boolean; + error?: string | null; + path?: string | null; + /** Conversation id (X-ConversationId) — same field as call_logs.session_tag. */ + sessionTag?: string | null; +} + +export interface Lane { + startMs: number; + endMs: number; +} + +export type ViewMode = "follow" | "live" | "pan"; + +export const VISIBLE_WINDOW_MS = 5 * 60 * 1000; +export const BAR_HEIGHT = 28; +export const LANE_GAP = 4; +export const LANE_HEIGHT = BAR_HEIGHT + LANE_GAP; +export const HEADER_HEIGHT = 48; +export const AXIS_HEIGHT = 32; +export const MIN_BAR_WIDTH = 3; +export const DEFAULT_LIST_POLL_SECONDS = 2; +export const TIMELINE_LIST_POLL_STORAGE_KEY = "timelineListPollSeconds"; +export const FOLLOW_LINE_X = 0.75; +export const LIVE_LINE_FRACTION = 0.9; + +export function computeBarRange( + log: TimelineLog, + nowMs: number +): { startMs: number; endMs: number } { + const ts = new Date(log.timestamp).getTime(); + if (log.active) return { startMs: ts, endMs: nowMs }; + if (log.completed) return { startMs: ts, endMs: ts + (log.duration || 0) }; + return { startMs: ts - (log.duration || 0), endMs: ts }; +} + +export const MODE_META: Record = { + follow: { + labelKey: "follow", + descriptionKey: "followDescription", + }, + live: { + labelKey: "now", + descriptionKey: "nowDescription", + }, + pan: { + labelKey: "pan", + descriptionKey: "panDescription", + }, +}; + +export function formatTimeAxis(ms: number): string { + const d = new Date(ms); + const h = d.getHours().toString().padStart(2, "0"); + const m = d.getMinutes().toString().padStart(2, "0"); + const s = d.getSeconds().toString().padStart(2, "0"); + return `${h}:${m}:${s}`; +} + +export function getStatusColor(status: number, active: boolean | undefined): string { + if (active) return "#6366F1"; + return getHttpStatusStyle(status).bg; +} + +export const DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS = 2 * 60 * 1000; + +// Exported so other components (e.g. the "Full Conversation" transcript panel +// in RequestLoggerDetail.tsx) can decide "is this conversation still in +// progress" using the SAME setting as the timeline's lane-reuse window, +// rather than a separate, potentially-inconsistent one. +export const CONVERSATION_LANE_REUSE_STORAGE_KEY = "timelineConversationLaneReuseMinutes"; + +/** + * Assigns each item a lane (row) index. Items sharing a `sessionTag` + * (conversation id) are forced onto the same lane as long as the gap since + * that lane's last item is within `reuseWindowMs` — after that, the lane is + * free again and falls back to the normal greedy overlap-avoidance packing + * below (unrelated to any conversation). + */ +export function allocateLanes( + items: TimelineLog[], + nowMs: number, + reuseWindowMs: number = DEFAULT_CONVERSATION_LANE_REUSE_WINDOW_MS +): Map { + const lanes: Lane[] = []; + const laneConversation: (string | null)[] = []; + const laneMap = new Map(); + + const sorted = [...items].sort((a, b) => { + const aStart = new Date(a.timestamp).getTime(); + const bStart = new Date(b.timestamp).getTime(); + return aStart - bStart; + }); + + for (const item of sorted) { + const { startMs, endMs } = computeBarRange(item, nowMs); + const conversationId = item.sessionTag || null; + + let placed = false; + + if (conversationId) { + for (let i = 0; i < lanes.length; i++) { + if (laneConversation[i] === conversationId && startMs - lanes[i].endMs <= reuseWindowMs) { + lanes[i] = { startMs, endMs }; + laneMap.set(item.id, i); + placed = true; + break; + } + } + } + + if (!placed) { + for (let i = 0; i < lanes.length; i++) { + if (lanes[i].endMs < startMs) { + lanes[i] = { startMs, endMs }; + laneConversation[i] = conversationId; + laneMap.set(item.id, i); + placed = true; + break; + } + } + } + if (!placed) { + laneMap.set(item.id, lanes.length); + laneConversation.push(conversationId); + lanes.push({ startMs, endMs }); + } + } + + return laneMap; +} + +export function truncateModel(model: string | null): string { + if (!model) return ""; + const parts = model.split("/"); + const short = parts[parts.length - 1]; + return short.length > 16 ? short.slice(0, 15) + "…" : short; +} + +export function formatDateLabel(ms: number): string { + const d = new Date(ms); + const months = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; + return `${months[d.getMonth()]} ${d.getDate()}`; +} diff --git a/src/shared/components/lobeProviderIcons.ts b/src/shared/components/lobeProviderIcons.ts index e0e2de437f..f9a75bfe16 100644 --- a/src/shared/components/lobeProviderIcons.ts +++ b/src/shared/components/lobeProviderIcons.ts @@ -402,7 +402,6 @@ const LOBE_PROVIDER_ALIASES = { "meta-llama": "Meta", minimax: "Minimax", "minimax-cn": "Minimax", - mimocode: "XiaomiMiMo", mistral: "Mistral", mistralai: "Mistral", moonshot: "Moonshot", diff --git a/src/shared/constants/alibabaProviderRegions.ts b/src/shared/constants/alibabaProviderRegions.ts index cd8b61a464..478e150de5 100644 --- a/src/shared/constants/alibabaProviderRegions.ts +++ b/src/shared/constants/alibabaProviderRegions.ts @@ -11,9 +11,14 @@ export const ALIBABA_PROVIDER_ENDPOINTS: Readonly< "global-sg": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "china-beijing": "https://dashscope.aliyuncs.com/compatible-mode/v1", }, + // The catalog entry is the personal TOKEN Plan (see providers/apikey/regional.ts: + // name "Alibaba Token Plan"). The legacy coding-intl/coding hosts serve the separate + // Coding Plan product and reject Token Plan keys with 401 invalid_api_key — verified + // live 2026-08-18 against the same key that returns 429 (quota) on the host below. + // Keeps /apps/anthropic/v1 because the registry entry is format "claude". "bailian-coding-plan": { - "global-sg": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", - "china-beijing": "https://coding.dashscope.aliyuncs.com/apps/anthropic/v1", + "global-sg": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", + "china-beijing": "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic/v1", }, "qwen-cloud": { "global-sg": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", @@ -74,11 +79,48 @@ function normalizeEndpoint(value: string): string { .toLowerCase(); } +/** + * Preset hosts this family used to ship. They must keep counting as presets: a connection + * saved while a preset was current carries that URL in providerSpecificData.baseUrl, and if + * a retired preset were mistaken for a deliberate custom URL the connection would stay + * pinned to a host that no longer accepts its key, deaf to the region selector. + */ +const LEGACY_FAMILY_PRESETS: Readonly> = { + alibaba: [], + // Retired 2026-08-18 — Coding Plan hosts, wrong product for this Token Plan entry. + "bailian-coding-plan": [ + "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + "https://coding.dashscope.aliyuncs.com/apps/anthropic/v1", + ], + "qwen-cloud": [], + "qwen-cloud-token-plan": [], +}; + +/** + * Media (AIGC) roots, when they differ from the chat root. + * + * Only bailian-coding-plan diverges: its CHAT traffic moved to the Token Plan host + * (2026-08), but image/video generation keeps running on the DashScope AIGC service + * (`/api/v1/services/aigc/…`) — see imageRegistry.ts / videoRegistry.ts, which pin those + * hosts literally. Deriving media from the chat root would have silently repointed every + * Bailian image/video call at a host that does not serve AIGC. + */ +const ALIBABA_PROVIDER_MEDIA_OVERRIDES: Partial< + Record>> +> = { + "bailian-coding-plan": { + "global-sg": "https://coding-intl.dashscope.aliyuncs.com/api/v1", + "china-beijing": "https://coding.dashscope.aliyuncs.com/api/v1", + }, +}; + function isFamilyPresetUrl(family: AlibabaProviderFamily, value: string): boolean { const normalized = normalizeEndpoint(value); - return ALIBABA_PROVIDER_REGION_VALUES.some( + const isCurrentPreset = ALIBABA_PROVIDER_REGION_VALUES.some( (region) => normalizeEndpoint(ALIBABA_PROVIDER_ENDPOINTS[family][region]) === normalized ); + if (isCurrentPreset) return true; + return LEGACY_FAMILY_PRESETS[family].some((preset) => normalizeEndpoint(preset) === normalized); } export function isAlibabaRegionalProvider(providerId: string | null | undefined): boolean { @@ -167,6 +209,22 @@ export function resolveAlibabaProviderMediaBaseUrl( providerSpecificData?: unknown, fallback = "" ): string { + const family = canonicalProviderFamily(providerId); + const data = asRecord(providerSpecificData); + const configuredBaseUrl = + typeof data.baseUrl === "string" && data.baseUrl.trim() ? data.baseUrl.trim() : ""; + const mediaOverride = family ? ALIBABA_PROVIDER_MEDIA_OVERRIDES[family] : undefined; + + // A custom base URL still drives media, as before — the override only replaces the + // preset-derived host. + if ( + family && + mediaOverride && + (!configuredBaseUrl || isFamilyPresetUrl(family, configuredBaseUrl)) + ) { + return mediaOverride[resolveAlibabaProviderRegion(providerId, data)]; + } + return stripTrailingSlashes( resolveAlibabaProviderBaseUrl(providerId, providerSpecificData, fallback).trim() ) diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index acdba14050..86907635d2 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -239,6 +239,7 @@ export const CLI_TOOLS: Record = { acpSpawnable: false, baseUrlSupport: "none", modelAliases: [ + "gemini-3.7-flash", "gemini-3.6-flash-high", "gemini-3.6-flash-medium", "gemini-3.6-flash-low", @@ -252,6 +253,7 @@ export const CLI_TOOLS: Record = { "gpt-oss-120b-medium", ], defaultModels: [ + createCliModel("gemini-3.7-flash", "Gemini 3.7 Flash"), createCliModel("gemini-3.6-flash-high", "Gemini 3.6 Flash High"), createCliModel("gemini-3.6-flash-medium", "Gemini 3.6 Flash Medium"), createCliModel("gemini-3.6-flash-low", "Gemini 3.6 Flash Low"), diff --git a/src/shared/constants/config.ts b/src/shared/constants/config.ts index 0ec376305a..eacf73db7e 100644 --- a/src/shared/constants/config.ts +++ b/src/shared/constants/config.ts @@ -38,7 +38,8 @@ export const PROVIDER_ENDPOINTS = { helixmind: "https://helixmind.online/v1/chat/completions", glm: "https://api.z.ai/api/anthropic/v1/messages", glmt: "https://api.z.ai/api/anthropic/v1/messages", - "bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages", + "bailian-coding-plan": + "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1/messages", "qwen-cloud": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", "qwen-cloud-token-plan": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions", diff --git a/src/shared/constants/endpointCategories.ts b/src/shared/constants/endpointCategories.ts index abaaeb7a2f..9976af5120 100644 --- a/src/shared/constants/endpointCategories.ts +++ b/src/shared/constants/endpointCategories.ts @@ -34,7 +34,7 @@ export const ENDPOINT_CATEGORIES: readonly EndpointCategory[] = [ id: "embeddings", label: "Embeddings", description: "Text embeddings generation", - prefixes: ["/v1/embeddings"], + prefixes: ["/v1/embeddings", "/v1/multimodal-embeddings"], }, { id: "images", diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index ed7e74c652..98baeafbec 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -12,7 +12,7 @@ export interface FeatureFlagDefinition { } export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ - // ──────────────── Security (9) ──────────────── + // ──────────────── Security (10) ──────────────── { key: "REQUIRE_API_KEY", label: "Require API Key", @@ -105,6 +105,20 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "danger", }, + { + key: "AUTH_LOG_INCLUDE_ACCOUNT_ID", + label: "Log Account IDs", + description: + "Include the account ID prefix in AUTH log lines (e.g. \"Using account: abc12345...\"). " + + "Disabled by default so the account identifier is redacted in shared/multi-tenant process logs. " + + "Independent of Debug Mode — flipping Debug Mode on does not reveal this.", + descriptionI18nKey: "featureFlagAuthLogIncludeAccountIdDescription", + category: "security", + defaultValue: "false", + type: "boolean", + requiresRestart: false, + warningLevel: "info", + }, // ──────────────── Network (7) ──────────────── { key: "ENABLE_TLS_FINGERPRINT", @@ -457,6 +471,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "info", }, + { + key: "OMNIROUTE_CHAT_VIRTUAL_LANES", + label: "Adaptive Virtual Admission Lanes", + description: + "Enable per-tenant adaptive virtual admission lanes for provider dispatch (#9654): one tenant's burst no longer 503s another. The OMNIROUTE_CHAT_VIRTUAL_LANES env var wins over this dashboard override; changes take effect at server restart.", + descriptionI18nKey: "featureFlagChatVirtualLanesEnabledDescription", + category: "runtime", + defaultValue: "false", + type: "boolean", + requiresRestart: true, + warningLevel: "info", + }, { key: "EXPOSE_FUNCTIONAL_GATEWAY_MIRRORS", label: "Functional Gateway Mirrors", diff --git a/src/shared/constants/modalityBridgeDefaults.ts b/src/shared/constants/modalityBridgeDefaults.ts index c267131114..e50d710117 100644 --- a/src/shared/constants/modalityBridgeDefaults.ts +++ b/src/shared/constants/modalityBridgeDefaults.ts @@ -8,6 +8,7 @@ import { VISION_BRIDGE_DEFAULTS } from "./visionBridgeDefaults"; export type VisionBridgeMode = "auto" | "describe" | "reroute"; +export type VideoSamplingPolicy = "uniform" | "scene_aware" | "segment_aware"; export const VIDEO_BRIDGE_TIMEOUT_MIN_MS = 1_000; export const VIDEO_BRIDGE_TIMEOUT_MAX_MS = 120_000; @@ -27,6 +28,7 @@ export const MODALITY_BRIDGE_DEFAULTS = { videoEnabled: false, videoModel: "", videoFrameCount: 8, + videoSamplingPolicy: "uniform" as VideoSamplingPolicy, videoMaxVideos: 1, videoTimeoutMs: 120000, } as const; @@ -59,6 +61,7 @@ export interface VideoBridgeRuntimeSettings { enabled: boolean; model: string; frameCount: number; + samplingPolicy: VideoSamplingPolicy; maxVideos: number; timeoutMs: number; cacheEnabled: boolean; @@ -146,6 +149,11 @@ export function resolveVideoBridgeRuntimeSettings( model: pickString(s.modalityBridgeVideoModel) ?? MODALITY_BRIDGE_DEFAULTS.videoModel, frameCount: pickNumber(s.modalityBridgeVideoFrameCount) ?? MODALITY_BRIDGE_DEFAULTS.videoFrameCount, + samplingPolicy: + pickString(s.modalityBridgeVideoSamplingPolicy) === "scene_aware" || + pickString(s.modalityBridgeVideoSamplingPolicy) === "segment_aware" + ? (pickString(s.modalityBridgeVideoSamplingPolicy) as VideoSamplingPolicy) + : MODALITY_BRIDGE_DEFAULTS.videoSamplingPolicy, maxVideos: pickNumber(s.modalityBridgeVideoMaxVideos) ?? MODALITY_BRIDGE_DEFAULTS.videoMaxVideos, timeoutMs: Math.min( diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 8afe74d3f3..b2ecabbc6c 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -174,6 +174,18 @@ export const MODEL_SPECS: Record = { thinkingBudgetCap: 0, }, + // ── Gemini 3.7 Flash (Antigravity) — collapsed live id ────────── + // Upstream (fetchAvailableModels on daily-cloudcode-pa) also serves this model as a + // single `gemini-3.7-flash-tiered` id via the `gemini-3.7-flash` alias in + // antigravityModelAliases.ts. Registered independently of the suffixed tier ids below + // (#3696 uniqueness invariant: each public id resolves to a distinct upstream id). + "gemini-3.7-flash": { + ...GEMINI_35_FLASH_MODEL_SPEC, + defaultThinkingBudget: 8192, + thinkingBudgetCap: 65536, + supportsThinking: true, + }, + // ── 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 diff --git a/src/shared/constants/models.ts b/src/shared/constants/models.ts index f78d64858f..56a9627584 100644 --- a/src/shared/constants/models.ts +++ b/src/shared/constants/models.ts @@ -28,7 +28,7 @@ const PASSTHROUGH_PROVIDERS = new Set( ); // Wrap isValidModel with passthrough providers -export function isValidModel(aliasOrId, modelId) { +export function isValidModel(aliasOrId: string, modelId: string) { if (isOpenAICompatibleProvider(aliasOrId)) return true; if (isAnthropicCompatibleProvider(aliasOrId)) return true; if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true; diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index 7c4a8406c4..b03db8be0c 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -319,6 +319,13 @@ export const DEFAULT_PRICING_OAUTH = { // downstream cost and quota calculations silently fall back to $0. // Pricing: $1.50 input / $7.50 output / $0.15 cached per MTok. Thinking tokens // billed at output rate. + "gemini-3.7-flash": { + input: 1.5, + output: 7.5, + cached: 0.15, + reasoning: 7.5, + cache_creation: 1.5, + }, "gemini-3.6-flash-low": { input: 1.5, output: 7.5, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 526ec3ed9a..d50c63e32a 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -30,7 +30,6 @@ export const FREE_PROVIDERS = {}; export const FREE_APIKEY_PROVIDER_IDS = new Set([ "qoder", - "mimocode", "opencode", "dahl", // auggie is a fully local, credential-less CLI passthrough (auth handled by @@ -40,6 +39,10 @@ export const FREE_APIKEY_PROVIDER_IDS = new Set([ "auggie", // zcode is a local app-server backend; auth stays in the ZCode profile. "zcode", + // AI Horde works anonymously (`0000000000`) and also accepts a free registered + // key for higher queue priority. The no-auth page still enables the provider; + // this flag admits an optional apikey connection so that stored key is used. + "aihorde", ]); export function supportsApiKeyOnFreeProvider(providerId: unknown): boolean { @@ -525,6 +528,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "bailian-coding-plan", // Qwen Cloud / Model Studio personal Token Plan (cookie-authenticated console gateway) "qwen-cloud-token-plan", + // AgentRouter (New-API) console balance quota (consoleApiKey + newApiUserId) + "agentrouter", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/src/shared/constants/providers/apikey/specialty-media.ts b/src/shared/constants/providers/apikey/specialty-media.ts index 3cc504f0bc..88a5049b2b 100644 --- a/src/shared/constants/providers/apikey/specialty-media.ts +++ b/src/shared/constants/providers/apikey/specialty-media.ts @@ -152,12 +152,13 @@ export const APIKEY_PROVIDERS_SPECIALTY = { "jina-ai": { id: "jina-ai", alias: "jina", - name: "Jina AI", + name: "Jina AI (Foundation API)", icon: "sort", color: "#2563EB", textIcon: "JA", website: "https://jina.ai", - authHint: "Bearer API key for the Jina AI rerank API.", + authHint: + "Bearer API key for api.jina.ai — embeddings, rerank, classify, segment, and search. Dashboard keys take precedence over JINA_AI_API_KEY. This is not the Reader / r.jina.ai card and does not fetch URLs.", hasFree: true, freeNote: "10M free tokens on signup (non-commercial), no credit card required", }, @@ -262,14 +263,16 @@ export const APIKEY_PROVIDERS_SPECIALTY = { "jina-reader": { id: "jina-reader", alias: "jr", - name: "Jina Reader", + name: "Jina Reader (r.jina.ai)", icon: "menu_book", color: "#0EA5E9", textIcon: "JR", website: "https://jina.ai/reader", + authHint: + "Bearer API key for r.jina.ai URL-to-markdown (/v1/web/fetch only). Does not serve /v1/embeddings or /v1/rerank. The same Jina token as Foundation API works; OmniRoute reuses a jina-ai dashboard key or JINA_AI_API_KEY when this card is empty.", hasFree: true, notice: { - text: "Free tier: 1M fetches/month.", + text: "Reader / r.jina.ai only — not embeddings or rerank. Free tier: 1M fetches/month.", apiKeyUrl: "https://jina.ai/api-dashboard", }, serviceKinds: ["webFetch"], diff --git a/src/shared/constants/providers/noauth.ts b/src/shared/constants/providers/noauth.ts index b572b68888..f7a0f1082a 100644 --- a/src/shared/constants/providers/noauth.ts +++ b/src/shared/constants/providers/noauth.ts @@ -55,6 +55,25 @@ export const NOAUTH_PROVIDERS = { // #7286: tools[] is prompt-emulated via webTools.ts (parseToolCallsFromText). toolCalling: "emulated", }, + "cloudflare-playground": { + id: "cloudflare-playground", + alias: "cfp", + name: "Cloudflare AI Playground", + icon: "cloud", + color: "#F38020", + textIcon: "CF", + website: "https://playground.ai.cloudflare.com", + noAuth: true, + hasFree: true, + serviceKinds: ["llm"], + freeNote: + "Free — Cloudflare's AI Playground: GLM 5.2, Kimi K2.7 Code, DeepSeek V4 Pro, gpt-oss-120B and 16 more. No account, no API key.", + authHint: + "No credentials required — anonymous browser sessions over a reverse-engineered cf_agent WebSocket protocol (Playwright transport).", + notice: { + text: "Cloudflare AI Playground uses a reverse-engineered anonymous WebSocket protocol (no official API). Requires Playwright with a Chromium browser on first request. Rate limits apply per IP (error 3021).", + }, + }, "felo-web": { id: "felo-web", alias: "felo", @@ -118,25 +137,6 @@ export const NOAUTH_PROVIDERS = { freeNote: "Free video generation — VEO 3.1, Seedance. 6 requests/hour.", authHint: "No auth required. Rate limited to 6 requests/hour per IP.", }, - mimocode: { - id: "mimocode", - alias: "mcode", - name: "MiMoCode (Free)", - icon: "devices", - color: "#FF6B35", - textIcon: "MC", - website: "https://mimo.mi.com", - noAuth: true, - hasFree: true, - serviceKinds: ["llm"], - freeNote: - "Free — Xiaomi MiMo models via bootstrap JWT auth. No API key required. Supports streaming.", - authHint: - "No API key required. The executor auto-generates JWT tokens via device fingerprint bootstrap.", - notice: { - text: "MiMoCode uses Xiaomi's public free AI endpoint with bootstrap-based JWT authentication. No signup needed. Rate limits apply.", - }, - }, auggie: { id: "auggie", alias: "aug", @@ -192,7 +192,7 @@ export const NOAUTH_PROVIDERS = { freeNote: "Crowdsourced inference from volunteer GPUs. Throughput is a shared queue, not a quota: there is no RPM/RPD cap, but waits grow when the network is busy.", notice: { - text: "AI Horde routes to volunteer-run workers, so responses can take minutes and tool calling is unavailable. Model availability changes as workers come and go.", + text: "AI Horde routes to volunteer-run workers, so chat and image jobs can take minutes and tool calling is unavailable. Chat models come from the live oai.aihorde.net catalog. Image models are listed only while Horde reports at least one worker. An optional aihorde.net API key raises queue priority (kudos).", }, }, }; diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts index 9056104dbd..967b64215f 100644 --- a/src/shared/constants/sidebarVisibility/sections.ts +++ b/src/shared/constants/sidebarVisibility/sections.ts @@ -410,6 +410,13 @@ const LOGS_GROUP: SidebarItemGroup = { subtitleKey: "logsTimelineSubtitle", icon: "view_timeline", }, + { + id: "conversations", + href: "/dashboard/conversations", + i18nKey: "conversations", + subtitleKey: "conversationsSubtitle", + icon: "forum", + }, ], }; diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts index 9bacd7ba88..3bb6330ed0 100644 --- a/src/shared/constants/sidebarVisibility/types.ts +++ b/src/shared/constants/sidebarVisibility/types.ts @@ -55,6 +55,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [ "logs-proxy", "logs-console", "logs-timeline", + "conversations", "logs-activity", "health", "runtime", diff --git a/src/shared/middleware/chatBodyAdmission.ts b/src/shared/middleware/chatBodyAdmission.ts index e85fce30ec..3a7d7fa132 100644 --- a/src/shared/middleware/chatBodyAdmission.ts +++ b/src/shared/middleware/chatBodyAdmission.ts @@ -17,6 +17,7 @@ import { CORS_HEADERS } from "../utils/cors"; import { createHash } from "crypto"; +import v8 from "node:v8"; function parsePositiveInt(value: string | undefined, fallback: number): number { const parsed = Number.parseInt(String(value), 10); @@ -80,6 +81,60 @@ export const CHAT_HEAVY_ESTIMATED_TOKENS = parsePositiveInt( process.env.OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS, 32_000 ); + +/** + * Heap-pressure shed ratio for the structural admission gate (#10183, #10268). + * + * 3.8.48 only shed a heavy request once `heapUsed / heapLimit >= shedRatio` (0.75). + * 3.8.49 (#9654/#9940) replaced that heap-conditional shed with an unconditional + * `CHAT_MAX_HEAVY_IN_FLIGHT=1` structural lease, so a second concurrent "heavy" + * request (coding-agent fan-out is the common trigger) was hard-rejected with a + * retryable 503 even on a host with ample free RAM. This restores the heap + * condition as an ADDITIONAL gate layered on top of the bounded-concurrency / + * per-connection-lane protection from #9654 (that protection stays in force — + * this constant only decides whether a *busy* lease is still shed with a 503 or + * admitted anyway because the heap has real headroom). + */ +export const CHAT_ADMISSION_HEAP_SHED_RATIO = (() => { + const parsed = Number(process.env.OMNIROUTE_CHAT_ADMISSION_HEAP_SHED_RATIO); + return Number.isFinite(parsed) && parsed > 0 && parsed <= 1 ? parsed : 0.75; +})(); + +/** + * Bounded extra capacity for the "healthy heap" fast path (#10437). + * + * The #10183/#10268 fix above admits a busy heavyweight request immediately whenever + * `heapPressureCheck()` is false — but with no bound of its own, that path let an + * UNLIMITED number of "healthy heap" requests pile in ahead of the heap-pressure + * shed, defeating the point of admission control: a slow leak or a burst that never + * quite trips the heap-pressure ratio could still starve the process. This constant + * caps how many requests may bypass the primary `CHAT_MAX_HEAVY_IN_FLIGHT` lease via + * the healthy-heap path at once (tracked independently, per `ChatAdmissionController` + * instance — see `#activeHealthy` / `tryAcquireHealthyHeadroom`). Once this budget is + * also exhausted, requests fall through to the SAME bounded-wait/shed path used under + * real heap pressure, so there is still a real ceiling either way. + */ +export const CHAT_ADMISSION_HEALTHY_HEADROOM = parseNonNegativeInt( + process.env.OMNIROUTE_CHAT_ADMISSION_HEALTHY_HEADROOM, + CHAT_MAX_HEAVY_IN_FLIGHT +); + +/** + * Live `heapUsed / heap_size_limit` pressure probe, injectable for deterministic + * tests (`admitChatStructure({ heapPressureCheck })`). Defaults to the real V8 + * heap statistics. Any read failure is treated as "not under pressure" so a + * transient stats error never turns into a false structural shed. + */ +export function defaultHeapPressureCheck(): boolean { + try { + const heapUsed = process.memoryUsage().heapUsed; + const heapLimit = v8.getHeapStatistics().heap_size_limit; + if (!Number.isFinite(heapLimit) || heapLimit <= 0) return false; + return heapUsed / heapLimit >= CHAT_ADMISSION_HEAP_SHED_RATIO; + } catch { + return false; + } +} /** * Optional per-deployment history cap. `0` (the default) disables it. * @@ -122,6 +177,11 @@ interface AdmissionWaiter { export class ChatAdmissionController { #activeHeavy = 0; #queuedBytes = 0; + /** #10437: independent counter for the bounded "healthy heap" headroom budget — + * separate from `#activeHeavy` so it never inflates the documented + * `CHAT_MAX_HEAVY_IN_FLIGHT` bound, but still a real, finite ceiling instead of + * the unconditional bypass this replaces. */ + #activeHealthy = 0; /** 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). */ @@ -132,7 +192,11 @@ export class ChatAdmissionController { constructor( readonly maxHeavyInFlight = 1, - readonly maxQueuedBytes = CHAT_ADMISSION_MAX_QUEUED_BYTES + readonly maxQueuedBytes = CHAT_ADMISSION_MAX_QUEUED_BYTES, + /** #10437: bounded extra capacity for the healthy-heap fast path. `0` disables + * the bypass entirely — every busy request then falls through to the same + * bounded-wait/shed path used under real heap pressure. */ + readonly healthyHeadroom = CHAT_ADMISSION_HEALTHY_HEADROOM ) { if (!Number.isSafeInteger(maxHeavyInFlight) || maxHeavyInFlight < 1) { throw new RangeError("maxHeavyInFlight must be a positive integer"); @@ -140,12 +204,44 @@ export class ChatAdmissionController { if (!Number.isSafeInteger(maxQueuedBytes) || maxQueuedBytes < 0) { throw new RangeError("maxQueuedBytes must be a non-negative integer"); } + if (!Number.isSafeInteger(healthyHeadroom) || healthyHeadroom < 0) { + throw new RangeError("healthyHeadroom must be a non-negative integer"); + } } get activeHeavy(): number { return this.#activeHeavy; } + /** Active leases held through the bounded healthy-heap headroom budget (#10437). */ + get activeHealthyHeadroom(): number { + return this.#activeHealthy; + } + + /** + * Acquire one slot from the bounded, independent healthy-heap headroom budget + * (#10437). Unlike `tryAcquireHeavy()`, this never contends with the primary + * `maxHeavyInFlight` lease — it exists ONLY to give the "heap has real + * headroom" fast path a finite ceiling instead of an unconditional bypass. + * Returns `null` once `healthyHeadroom` concurrent leases are already active, + * at which point the caller must fall through to the bounded-wait/shed path. + */ + tryAcquireHealthyHeadroom(): ChatAdmissionLease | null { + if (this.#activeHealthy >= this.healthyHeadroom) return null; + this.#activeHealthy += 1; + let released = false; + return { + get released() { + return released; + }, + release: () => { + if (released) return; + released = true; + this.#activeHealthy = Math.max(0, this.#activeHealthy - 1); + }, + }; + } + /** Total buffered bytes currently parked across all queues (heap valve accounting). */ get queuedBytes(): number { return this.#queuedBytes; @@ -334,18 +430,25 @@ export function resolveSessionId(request: Request): string { // 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. + // CodeQL: Intentionally SHA-256, NOT password hashing. The digest is a + // deterministic, non-reversible per-key fairness key for the shared + // admission budget — never stored or used for password-style verification. + // codeql[js/insufficient-password-hash] const authHeader = request.headers.get("authorization") || ""; const bearerMatch = /^bearer\s+(\S+)$/i.exec(authHeader.trim()); if (bearerMatch) { - return "key_" + createHash("sha256").update(bearerMatch[1]).digest("hex").slice(0, 16); + // codeql[js/insufficient-password-hash] + return "key_" + createHash("sha256").update(bearerMatch[1]).digest("hex").slice(0, 16); // nosemgrep: insufficient-password-hash } const xApiKey = request.headers.get("x-api-key") || ""; if (xApiKey.trim().length > 0) { - return "key_" + createHash("sha256").update(xApiKey.trim()).digest("hex").slice(0, 16); + // codeql[js/insufficient-password-hash] + return "key_" + createHash("sha256").update(xApiKey.trim()).digest("hex").slice(0, 16); // nosemgrep: insufficient-password-hash } const xGoogApiKey = request.headers.get("x-goog-api-key") || ""; if (xGoogApiKey.trim().length > 0) { - return "key_" + createHash("sha256").update(xGoogApiKey.trim()).digest("hex").slice(0, 16); + // codeql[js/insufficient-password-hash] + return "key_" + createHash("sha256").update(xGoogApiKey.trim()).digest("hex").slice(0, 16); // nosemgrep: insufficient-password-hash } return "anonymous"; } @@ -523,6 +626,12 @@ export async function admitChatStructure( heavyTokens?: number; queueMs?: number; signal?: AbortSignal; + /** + * Heap-pressure probe consulted only when heavyweight capacity is busy + * (#10183, #10268). Defaults to `defaultHeapPressureCheck` (live V8 heap + * stats). Tests inject a deterministic override. + */ + heapPressureCheck?: () => boolean; } = {} ): Promise { if (!body || typeof body !== "object" || Array.isArray(body)) return { admit: true, lease }; @@ -560,6 +669,34 @@ export async function admitChatStructure( (options.sessionId ? perConnectionAdmissionController.getController(options.sessionId) : defaultAdmissionController); + + // Uncontended fast path: capacity is free, no need to consult heap pressure at all. + const immediate = controller.tryAcquireHeavy(); + if (immediate) return { admit: true, lease: immediate }; + + // Heavyweight capacity is momentarily busy (a concurrent heavy request holds the + // lease). #10183 / #10268: only enter the bounded-wait / shed path — with its + // queued-bytes heap valve and abort handling (#9654) — when the heap is + // GENUINELY under pressure. This restores the 3.8.48 `heapUsed/heapLimit >= + // shedRatio` condition as an additional gate on top of (never a replacement + // for) the bounded-concurrency / per-connection-lane protection above. A + // healthy heap has real headroom for a second heavy request even while the + // single lease is momentarily busy, so admit it immediately instead of + // parking/shedding a request that has nothing to do with actual resource + // pressure. + const heapPressureCheck = options.heapPressureCheck ?? defaultHeapPressureCheck; + if (!heapPressureCheck()) { + // #10437: the healthy-heap fast path must still have a real ceiling — an + // unconditional bypass here let unlimited concurrent "healthy heap" + // requests pile in ahead of the heap-pressure shed, defeating admission + // control entirely. Reserve from a separate, bounded headroom budget + // instead of an unconditional no-op lease; only fall through to the + // bounded-wait/shed path below (identical to the real-pressure case) once + // that budget is also exhausted. + const headroomLease = controller.tryAcquireHealthyHeadroom(); + if (headroomLease) return { admit: true, lease: headroomLease }; + } + // Structural-only waits happen on byte-light bodies (a byte-heavy body already // holds the byte-stage lease), so the conservative 256KB weight bounds the // parsed JSON the waiter keeps resident while parked. @@ -633,14 +770,18 @@ export function resolveSelfLoopBearer(): string { * gap that kept the Zoo Code / api-key describe call failing even after the byte * stage was bypassed. Release is a no-op; capacity was never reserved. */ -const NULL_LEASE: ChatAdmissionLease = { - get released() { - return true; - }, - release() { - // No-op: the sentinel never reserved heavyweight capacity. - }, -}; +function createNoopLease(): ChatAdmissionLease { + return { + get released() { + return true; + }, + release() { + // No-op: this sentinel never reserved heavyweight capacity. + }, + }; +} + +const NULL_LEASE: ChatAdmissionLease = createNoopLease(); /** * True when the request is a trusted in-process self-loop sub-request that must diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index 284618971c..9b47e68343 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -198,6 +198,34 @@ const CLI_TOOLS: Record = { env: ".qwen/.env", }, }, + aider: { + defaultCommand: "aider", + envBinKey: "CLI_AIDER_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 12000, + paths: { + config: ".aider.conf.yml", + }, + }, + goose: { + defaultCommand: "goose", + envBinKey: "CLI_GOOSE_BIN", + requiresBinary: true, + healthcheckTimeoutMs: 12000, + paths: { + config: ".config/goose/config.yaml", + }, + }, + gemini: { + defaultCommand: "gemini", + envBinKey: "CLI_GEMINI_BIN", + requiresBinary: true, + // gemini-cli cold start (bundle + extension discovery) can exceed 4s. + healthcheckTimeoutMs: 15000, + paths: { + settings: ".gemini/settings.json", + }, + }, // ── Plan 14 — new "custom" configType tools ─────────────────────────────── forge: { defaultCommand: "forge", @@ -286,6 +314,33 @@ const CLI_TOOLS: Record = { }, }; +/** + * Compatibility aliases accepted by CLI/API callers. + * + * The runtime catalog keeps one canonical id per executable. Older surfaces + * exposed a binary name (notably `kilocode`) or launcher aliases instead of + * that id, so normalize them at the boundary rather than duplicating entries. + */ +export const CLI_TOOL_ALIASES: Readonly> = { + kilocode: "kilo", + "kilo-code": "kilo", + kilo_cli: "kilo", + cc: "claude", + "claude-code": "claude", + "openai-codex": "codex", + openai: "codex", + cn: "continue", + qodercli: "qoder", +}; + +/** Resolve a user-facing or legacy id to the canonical runtime id. */ +export const normalizeCliToolId = (toolId: string): string => { + const normalized = String(toolId || "") + .trim() + .toLowerCase(); + return CLI_TOOL_ALIASES[normalized] || normalized; +}; + const isWindows = () => process.platform === "win32"; /** @@ -568,6 +623,7 @@ const getExtraPaths = () => * Works on all platforms — Windows checks .cmd wrappers, Linux/macOS checks bare names. */ export const getKnownToolPaths = (toolId: string): string[] => { + toolId = normalizeCliToolId(toolId); const home = os.homedir(); const paths: string[] = []; @@ -730,7 +786,7 @@ export const getLookupEnv = () => { }; const resolveToolCommands = (toolId: string): string[] => { - const tool = CLI_TOOLS[toolId]; + const tool = CLI_TOOLS[normalizeCliToolId(toolId)]; if (!tool) return []; const envCommand = String(process.env[tool.envBinKey] || "").trim(); if (envCommand) return [envCommand]; @@ -740,6 +796,16 @@ const resolveToolCommands = (toolId: string): string[] => { return tool.defaultCommand ? [tool.defaultCommand] : []; }; +/** + * Return command candidates without probing the filesystem. + * + * Lightweight consumers (config status and CLI inventory) use this to build + * a version probe while getCliRuntimeStatus() remains the authoritative + * health/runnability check. + */ +export const getCliToolCommandCandidates = (toolId: string): string[] => + resolveToolCommands(toolId); + const checkExplicitPath = async (commandPath: string) => { // Reject paths that look like injection attempts if (!isSafePath(commandPath)) { @@ -781,13 +847,13 @@ export const locateCommand = async (command: string, env: Record l.trim()) + .map((l: string) => l.trim()) .filter(Boolean); if (lines.length === 0) { return { installed: false, commandPath: null, reason: "not_found" }; } const winExt = /\.(cmd|exe|bat|com)$/i; - const preferred = lines.find((l) => winExt.test(l)) || lines[0]; + const preferred = lines.find((l: string) => winExt.test(l)) || lines[0]; return { installed: true, commandPath: normalizeMsys2Path(preferred), reason: null }; } return { installed: false, commandPath: null, reason: "not_found" }; @@ -1025,6 +1091,7 @@ export const resolveOpencodeConfigPath = ( export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath(); export const getCliConfigPaths = (toolId: string) => { + toolId = normalizeCliToolId(toolId); const tool = CLI_TOOLS[toolId]; if (!tool) return null; @@ -1071,6 +1138,7 @@ export const getCliPrimaryConfigPath = (toolId: string) => { }; export const getCliRuntimeStatus = async (toolId: string) => { + toolId = normalizeCliToolId(toolId); const tool = CLI_TOOLS[toolId]; const runtimeMode = getRuntimeMode(); if (!tool) { diff --git a/src/shared/services/modelSyncScheduler.ts b/src/shared/services/modelSyncScheduler.ts index 026ebee310..c705f6f977 100644 --- a/src/shared/services/modelSyncScheduler.ts +++ b/src/shared/services/modelSyncScheduler.ts @@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto"; import { Agent, buildConnector, fetch as undiciFetch, type Dispatcher } from "undici"; import { getSettings, updateSettings } from "@/lib/localDb"; +import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; import { getRuntimePorts } from "@/lib/runtime/ports"; const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours @@ -155,6 +156,11 @@ async function getAutoSyncConnections(): Promise< const autoSyncConnections: Array<{ id: string; provider: string; name?: string }> = []; for (const conn of connections) { if (!conn.isActive && conn.isActive !== undefined) continue; + if ( + typeof conn.id === "string" && + (await isConnectionUnavailableToAuxiliaryActivity(conn.id)) + ) + continue; const psd = conn.providerSpecificData && typeof conn.providerSpecificData === "object" ? (conn.providerSpecificData as Record) diff --git a/src/shared/utils/autoDisableBanned.ts b/src/shared/utils/autoDisableBanned.ts new file mode 100644 index 0000000000..fd834378f6 --- /dev/null +++ b/src/shared/utils/autoDisableBanned.ts @@ -0,0 +1,71 @@ +/** + * Auto-disable on permanent ban signals. + * + * Login seats (paid subscriptions and free accounts) can be locked by the + * upstream if OmniRoute keeps retrying after a ToS / "verify your account" + * 403. Paid prepaid API keys do not have that failure mode: a 429 is a + * cooldown and an empty wallet is a failover, not a reason to flip + * isActive=false. + * + * Per-provider and per-account overrides are the durable design. This helper + * is the global first cut: all connections vs login-style connections only. + */ + +export const AUTO_DISABLE_BANNED_SCOPES = ["all", "subscription"] as const; +export type AutoDisableBannedScope = (typeof AUTO_DISABLE_BANNED_SCOPES)[number]; + +const API_KEY_AUTH_TYPES = new Set(["apikey", "api_key"]); +const SUBSCRIPTION_AUTH_TYPES = new Set(["oauth", "cookie", "access_token", "session", "web"]); + +export function normalizeAutoDisableBannedScope(value: unknown): AutoDisableBannedScope { + return value === "subscription" ? "subscription" : "all"; +} + +export function isApiKeyAuthType(authType: string | null | undefined): boolean { + return API_KEY_AUTH_TYPES.has(String(authType || "").trim().toLowerCase()); +} + +export function isSubscriptionAuthType(authType: string | null | undefined): boolean { + return SUBSCRIPTION_AUTH_TYPES.has(String(authType || "").trim().toLowerCase()); +} + +function isWebCookieProvider( + providerId: string | null | undefined, + webCookieProviderIds?: Iterable | Record +): boolean { + const id = String(providerId || "") + .trim() + .toLowerCase(); + if (!id || !webCookieProviderIds) return false; + if (Array.isArray(webCookieProviderIds) || webCookieProviderIds instanceof Set) { + for (const item of webCookieProviderIds) { + if (String(item).toLowerCase() === id) return true; + } + return false; + } + return Object.keys(webCookieProviderIds).some((key) => key.toLowerCase() === id); +} + +export function isSubscriptionStyleConnection(input: { + authType?: string | null; + providerId?: string | null; + webCookieProviderIds?: Iterable | Record; +}): boolean { + if (isSubscriptionAuthType(input.authType)) return true; + if (isWebCookieProvider(input.providerId, input.webCookieProviderIds)) return true; + if (isApiKeyAuthType(input.authType)) return false; + // Unknown auth types keep today's conservative behavior. + return true; +} + +export function shouldAutoDisableBannedConnection(input: { + enabled?: boolean | null; + scope?: unknown; + authType?: string | null; + providerId?: string | null; + webCookieProviderIds?: Iterable | Record; +}): boolean { + if (!input.enabled) return false; + if (normalizeAutoDisableBannedScope(input.scope) === "all") return true; + return isSubscriptionStyleConnection(input); +} diff --git a/src/shared/utils/cors.ts b/src/shared/utils/cors.ts index 75ef54d5ac..a014b7dba4 100644 --- a/src/shared/utils/cors.ts +++ b/src/shared/utils/cors.ts @@ -11,7 +11,7 @@ export const CORS_HEADERS = { "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", "Access-Control-Allow-Headers": - "Content-Type, Authorization, x-api-key, anthropic-version, x-omniroute-connection, x-internal-test, accept", + "Content-Type, Authorization, x-api-key, anthropic-version, x-omniroute-connection, X-OmniRoute-Lease-Owner, X-OmniRoute-Lease-Generation, x-internal-test, accept", } as const; /** diff --git a/src/shared/utils/shuffleDeck.ts b/src/shared/utils/shuffleDeck.ts index 76d67d8136..1404b10474 100644 --- a/src/shared/utils/shuffleDeck.ts +++ b/src/shared/utils/shuffleDeck.ts @@ -138,6 +138,33 @@ export function getNextFromDeckSync(namespace: string, itemIds: readonly string[ return newOrder[0]; } +/** Plan a deck selection without advancing shared state until commit. */ +export function planNextFromDeckSync(namespace: string, itemIds: readonly string[]) { + if (itemIds.length === 0) return { selectedId: "", commit: () => {} }; + if (itemIds.length === 1) return { selectedId: itemIds[0], commit: () => {} }; + + const idsKey = [...itemIds].sort().join(","); + const existing = decks.get(namespace); + if (existing && existing.idsKey === idsKey && existing.index < existing.order.length) { + const selectedId = existing.order[existing.index]; + return { + selectedId, + commit: () => decks.set(namespace, { ...existing, index: existing.index + 1 }), + }; + } + + const lastUsedId = + existing && existing.idsKey === idsKey && existing.order.length > 0 + ? existing.order[existing.order.length - 1] + : undefined; + const order = fisherYatesShuffle(itemIds); + if (lastUsedId !== undefined && order[0] === lastUsedId && order.length > 1) { + const swapIdx = 1 + secureRandomInt(order.length - 1); + [order[0], order[swapIdx]] = [order[swapIdx], order[0]]; + } + return { selectedId: order[0], commit: () => decks.set(namespace, { order, index: 1, idsKey }) }; +} + // ─── Test helpers ─────────────────────────────────────────────────────────── /** Reset all decks — for testing only. */ diff --git a/src/shared/validation/geminiNativeEmbeddingInput.ts b/src/shared/validation/geminiNativeEmbeddingInput.ts new file mode 100644 index 0000000000..dc4562553f --- /dev/null +++ b/src/shared/validation/geminiNativeEmbeddingInput.ts @@ -0,0 +1,126 @@ +/** + * Gemini Embedding 2 native items (Google AI Studio embedContent / batchEmbedContents). + * + * Official 2026 contract (ai.google.dev/gemini-api/docs/embeddings): + * - Model id: gemini-embedding-2 (GA April 2026). Legacy text-only: gemini-embedding-001. + * - One Content (parts[]) → one embedding. Multiple parts in one Content fuse. + * - N Content objects / N batchEmbedContents requests → N embeddings. + * - Parts: { text }, { inline_data: { mime_type, data } }, { file_data: { mime_type, file_uri } }. + * CamelCase SDK spellings (inlineData / fileData) are accepted and forwarded. + * + * These are not OmniRoute's canonical `{ type, source }` items. For gemini + * they must reach generativelanguage.googleapis.com as Content parts — do + * not collapse the OpenAI `input` array to string[]. + */ + +import { isCanonicalEmbeddingItem, isPlainObject } from "./jinaNativeEmbeddingInput"; + +export type GeminiEmbeddingModality = "text" | "image" | "audio" | "video" | "document"; + +const GEMINI_EMBEDDING_2_IDS = new Set(["gemini-embedding-2", "gemini-embedding-2-preview"]); + +export function isGeminiEmbedding2Family(modelId: string | null | undefined): boolean { + return typeof modelId === "string" && GEMINI_EMBEDDING_2_IDS.has(modelId); +} + +function asRecord(value: unknown): Record | null { + return isPlainObject(value) ? value : null; +} + +function mimeFromInline(value: Record): string | null { + const snake = asRecord(value.inline_data); + if (typeof snake?.mime_type === "string") return snake.mime_type; + const camel = asRecord(value.inlineData); + if (typeof camel?.mimeType === "string") return camel.mimeType; + return null; +} + +function mimeFromFile(value: Record): string | null { + const snake = asRecord(value.file_data); + if (typeof snake?.mime_type === "string") return snake.mime_type; + const camel = asRecord(value.fileData); + if (typeof camel?.mimeType === "string") return camel.mimeType; + return null; +} + +export function modalityFromGeminiMime(mimeType: string): GeminiEmbeddingModality { + const mime = mimeType.trim().toLowerCase(); + if (mime.startsWith("image/")) return "image"; + if (mime.startsWith("audio/")) return "audio"; + if (mime.startsWith("video/")) return "video"; + if (mime === "application/pdf" || mime.startsWith("application/pdf")) return "document"; + return "document"; +} + +export function isGeminiNativePart(value: unknown): boolean { + const record = asRecord(value); + if (!record || isCanonicalEmbeddingItem(record)) return false; + if (typeof record.text === "string" && record.text.trim().length > 0) { + return !("image" in record) && !("audio" in record) && !("video" in record) && !("pdf" in record); + } + if (asRecord(record.inline_data)?.data || asRecord(record.inlineData)?.data) return true; + if (asRecord(record.file_data)?.file_uri || asRecord(record.fileData)?.fileUri) return true; + return false; +} + +export function isGeminiNativeContent(value: unknown): boolean { + const record = asRecord(value); + if (!record || isCanonicalEmbeddingItem(record)) return false; + if (!Array.isArray(record.parts) || record.parts.length === 0) return false; + return record.parts.every((part) => isGeminiNativePart(part)); +} + +export function isGeminiNativeEmbedRequest(value: unknown): boolean { + const record = asRecord(value); + if (!record || isCanonicalEmbeddingItem(record)) return false; + const content = record.content; + if (Array.isArray(content)) return false; + return isGeminiNativeContent(content); +} + +export function isGeminiNativeEmbeddingItem(value: unknown): boolean { + return isGeminiNativePart(value) || isGeminiNativeContent(value) || isGeminiNativeEmbedRequest(value); +} + +/** + * True when the request already uses Gemini's documented multimodal contract + * (a part, a Content with parts, or an EmbedContentRequest). + */ +export function isGeminiNativeEmbeddingInput(input: unknown): boolean { + if (isGeminiNativeEmbeddingItem(input)) return true; + if (!Array.isArray(input)) return false; + return input.some((item) => isGeminiNativeEmbeddingItem(item)); +} + +export function collectGeminiNativeModalities(input: unknown): GeminiEmbeddingModality[] { + const found = new Set(); + + const visitPart = (value: unknown) => { + const record = asRecord(value); + if (!record) return; + if (typeof record.text === "string" && record.text.trim().length > 0) found.add("text"); + const inlineMime = mimeFromInline(record); + if (inlineMime) found.add(modalityFromGeminiMime(inlineMime)); + const fileMime = mimeFromFile(record); + if (fileMime) found.add(modalityFromGeminiMime(fileMime)); + }; + + const visit = (value: unknown) => { + if (isGeminiNativeEmbedRequest(value)) { + visit((value as { content: unknown }).content); + return; + } + if (isGeminiNativeContent(value)) { + for (const part of (value as { parts: unknown[] }).parts) visitPart(part); + return; + } + if (isGeminiNativePart(value)) visitPart(value); + }; + + if (Array.isArray(input)) { + for (const item of input) visit(item); + } else { + visit(input); + } + return [...found]; +} diff --git a/src/shared/validation/jinaNativeEmbeddingInput.ts b/src/shared/validation/jinaNativeEmbeddingInput.ts new file mode 100644 index 0000000000..eab8ec4ef2 --- /dev/null +++ b/src/shared/validation/jinaNativeEmbeddingInput.ts @@ -0,0 +1,87 @@ +/** + * Jina Search Foundation native embedding items (api.jina.ai EmbeddingsV5Request). + * + * Official 2026 input shapes (OpenAPI 2026.07.27): + * TextDoc { text } + * ImageDoc { image } URL or base64 / data URI + * AudioDoc { audio } + * VideoDoc { video } + * PDFDoc { pdf } single input only upstream; we still accept it in a list + * MergedContentGroup { content: [TextDoc|ImageDoc|AudioDoc|VideoDoc, ...] } + * + * These are not OmniRoute's canonical `{ type, source }` items. For jina-ai + * they must be forwarded intact — do not stringify, do not fetch image URLs + * into data URIs. Jina fetches public media itself. + */ + +export const JINA_NATIVE_MEDIA_KEYS = ["text", "image", "audio", "video", "pdf"] as const; +export type JinaNativeMediaKey = (typeof JINA_NATIVE_MEDIA_KEYS)[number]; + +const NATIVE_KEY_TO_MODALITY: Record = + { + text: "text", + image: "image", + audio: "audio", + video: "video", + pdf: "document", + }; + +export function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** OmniRoute canonical structured item — leave those on the translator path. */ +export function isCanonicalEmbeddingItem(value: unknown): boolean { + return isPlainObject(value) && "type" in value && typeof value.type === "string"; +} + +export function isJinaNativeDoc(value: unknown): boolean { + if (!isPlainObject(value) || isCanonicalEmbeddingItem(value)) return false; + if ("content" in value && Array.isArray(value.content)) return false; + const present = JINA_NATIVE_MEDIA_KEYS.filter((key) => key in value); + if (present.length !== 1) return false; + return typeof value[present[0]] === "string" && String(value[present[0]]).trim().length > 0; +} + +export function isJinaMergedContentGroup(value: unknown): boolean { + if (!isPlainObject(value) || isCanonicalEmbeddingItem(value)) return false; + if (!Array.isArray(value.content) || value.content.length === 0) return false; + return value.content.every((item) => isJinaNativeDoc(item) && !("pdf" in (item as object))); +} + +export function isJinaNativeEmbeddingItem(value: unknown): boolean { + return isJinaNativeDoc(value) || isJinaMergedContentGroup(value); +} + +/** + * True when the request already uses Jina's documented multimodal contract + * (single doc, mixed string+doc batch, or fused content groups). + */ +export function isJinaNativeEmbeddingInput(input: unknown): boolean { + if (isJinaNativeEmbeddingItem(input)) return true; + if (!Array.isArray(input)) return false; + return input.some((item) => isJinaNativeEmbeddingItem(item)); +} + +export function collectJinaNativeModalities( + input: unknown +): Array<"text" | "image" | "audio" | "video" | "document"> { + const found = new Set<"text" | "image" | "audio" | "video" | "document">(); + + const visit = (value: unknown) => { + if (isJinaMergedContentGroup(value)) { + for (const item of (value as { content: unknown[] }).content) visit(item); + return; + } + if (!isJinaNativeDoc(value)) return; + const key = JINA_NATIVE_MEDIA_KEYS.find((mediaKey) => mediaKey in (value as object)); + if (key) found.add(NATIVE_KEY_TO_MODALITY[key]); + }; + + if (Array.isArray(input)) { + for (const item of input) visit(item); + } else { + visit(input); + } + return [...found]; +} diff --git a/src/shared/validation/schemas/apiV1.ts b/src/shared/validation/schemas/apiV1.ts index 4c740cb42d..ba8f61067b 100644 --- a/src/shared/validation/schemas/apiV1.ts +++ b/src/shared/validation/schemas/apiV1.ts @@ -20,6 +20,11 @@ import { } from "@/shared/reasoning/effortStandardization"; import { modelIdSchema, nonEmptyStringSchema } from "./misc.ts"; +import { + isCanonicalEmbeddingItem, + JINA_NATIVE_MEDIA_KEYS, +} from "../jinaNativeEmbeddingInput.ts"; +import { isGeminiNativeEmbeddingItem } from "../geminiNativeEmbeddingInput.ts"; export const embeddingTokenArraySchema = z .array(z.number().int().min(0)) @@ -110,15 +115,260 @@ export const embeddingMultimodalItemSchema = z.discriminatedUnion("type", [ ), ]); +function decodedInlineBytesFromEmbeddingItem(item: unknown): number { + if (!item || typeof item !== "object") return 0; + const record = item as Record; + if ( + "type" in record && + record.type !== "text" && + record.source && + typeof record.source === "object" + ) { + const source = record.source as { type?: string; data?: string }; + if (source.type === "base64" && typeof source.data === "string") { + return decodedBase64Bytes(source.data); + } + } + for (const key of JINA_NATIVE_MEDIA_KEYS) { + if (key === "text" || typeof record[key] !== "string") continue; + const value = String(record[key]); + const dataUri = /^data:([^;,]+);base64,(.+)$/i.exec(value); + if (dataUri) return decodedBase64Bytes(dataUri[2]); + if (/^https:\/\//i.test(value)) return 0; + return decodedBase64Bytes(value); + } + if (Array.isArray(record.content)) { + return record.content.reduce( + (total, chunk) => total + decodedInlineBytesFromEmbeddingItem(chunk), + 0 + ); + } + if (record.content && typeof record.content === "object" && !Array.isArray(record.content)) { + return decodedInlineBytesFromEmbeddingItem(record.content); + } + if (Array.isArray(record.parts)) { + return record.parts.reduce( + (total, chunk) => total + decodedInlineBytesFromEmbeddingItem(chunk), + 0 + ); + } + const inline = record.inline_data ?? record.inlineData; + if (inline && typeof inline === "object") { + const data = (inline as { data?: unknown }).data; + if (typeof data === "string") return decodedBase64Bytes(data); + } + return 0; +} + const embeddingMultimodalInputSchema = z .array(embeddingMultimodalItemSchema) .min(1, "input must contain at least one item") .max(MAX_EMBEDDING_INPUT_ITEMS, `input must contain at most ${MAX_EMBEDDING_INPUT_ITEMS} items`) .superRefine((items, context) => { - const totalBytes = items.reduce((total, item) => { - if (item.type === "text" || item.source.type !== "base64") return total; - return total + decodedBase64Bytes(item.source.data); - }, 0); + const totalBytes = items.reduce( + (total, item) => total + decodedInlineBytesFromEmbeddingItem(item), + 0 + ); + if (totalBytes > MAX_EMBEDDING_INLINE_TOTAL_BYTES) { + context.addIssue({ + code: "custom", + message: "decoded inline media must not exceed 16 MiB per request", + }); + } + }); + +function refineJinaMediaString(value: string, context: z.RefinementCtx) { + const trimmed = value.trim(); + if (/^https:\/\//i.test(trimmed)) { + if (trimmed.length > MAX_EMBEDDING_URL_LENGTH) { + context.addIssue({ code: "custom", message: "media URL is too long" }); + return; + } + try { + const url = parseAndValidatePublicUrl(trimmed); + if (url.protocol !== "https:") { + context.addIssue({ code: "custom", message: "media URLs must use HTTPS" }); + } + } catch { + context.addIssue({ code: "custom", message: "media URL must be a safe public HTTPS URL" }); + } + return; + } + if (/^(https?:|file:|data:text\/html)/i.test(trimmed) && !trimmed.startsWith("data:")) { + context.addIssue({ code: "custom", message: "media URL must be a safe public HTTPS URL" }); + return; + } + const dataUri = /^data:([^;,]+);base64,(.+)$/i.exec(trimmed); + const payload = dataUri ? dataUri[2] : trimmed; + if ( + payload.length > MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH || + decodedBase64Bytes(payload) > MAX_EMBEDDING_INLINE_ITEM_BYTES + ) { + context.addIssue({ + code: "custom", + message: "decoded inline media must not exceed 8 MiB", + }); + } +} + +const jinaNativeMediaStringSchema = z.string().trim().min(1).superRefine(refineJinaMediaString); + +function exactlyOneJinaMediaKey(value: Record, key: string): boolean { + if (isCanonicalEmbeddingItem(value)) return false; + return JINA_NATIVE_MEDIA_KEYS.filter((mediaKey) => mediaKey in value).length === 1 && key in value; +} + +const jinaTextDocSchema = z + .object({ text: z.string().trim().min(1).max(MAX_EMBEDDING_TEXT_LENGTH) }) + .passthrough() + .refine((value) => exactlyOneJinaMediaKey(value, "text"), { + message: "Jina TextDoc must be { text }", + }); + +const jinaImageDocSchema = z + .object({ image: jinaNativeMediaStringSchema }) + .passthrough() + .refine((value) => exactlyOneJinaMediaKey(value, "image"), { + message: "Jina ImageDoc must be { image }", + }); + +const jinaAudioDocSchema = z + .object({ audio: jinaNativeMediaStringSchema }) + .passthrough() + .refine((value) => exactlyOneJinaMediaKey(value, "audio"), { + message: "Jina AudioDoc must be { audio }", + }); + +const jinaVideoDocSchema = z + .object({ video: jinaNativeMediaStringSchema }) + .passthrough() + .refine((value) => exactlyOneJinaMediaKey(value, "video"), { + message: "Jina VideoDoc must be { video }", + }); + +const jinaPdfDocSchema = z + .object({ pdf: jinaNativeMediaStringSchema }) + .passthrough() + .refine((value) => exactlyOneJinaMediaKey(value, "pdf"), { + message: "Jina PDFDoc must be { pdf }", + }); + +export const jinaNativeDocSchema = z.union([ + jinaTextDocSchema, + jinaImageDocSchema, + jinaAudioDocSchema, + jinaVideoDocSchema, + jinaPdfDocSchema, +]); + +export const jinaMergedContentGroupSchema = z + .object({ + content: z + .array(z.union([jinaTextDocSchema, jinaImageDocSchema, jinaAudioDocSchema, jinaVideoDocSchema])) + .min(1, "content must contain at least one chunk"), + }) + .passthrough(); + +const geminiInlineBlobSchema = z + .object({ + mime_type: z.string().trim().min(1).max(MAX_MEDIA_TYPE_LENGTH).optional(), + mimeType: z.string().trim().min(1).max(MAX_MEDIA_TYPE_LENGTH).optional(), + data: z.string().min(1), + }) + .passthrough() + .superRefine((value, context) => { + if (!value.mime_type && !value.mimeType) { + context.addIssue({ code: "custom", message: "Gemini inline_data requires mime_type" }); + } + const data = value.data; + if ( + data.length > MAX_EMBEDDING_INLINE_ITEM_BASE64_LENGTH || + decodedBase64Bytes(data) > MAX_EMBEDDING_INLINE_ITEM_BYTES + ) { + context.addIssue({ + code: "custom", + message: "decoded inline media must not exceed 8 MiB", + }); + } + }); + +const geminiFileUriSchema = z + .string() + .trim() + .min(1) + .max(MAX_EMBEDDING_URL_LENGTH) + .superRefine((value, context) => { + if (value.startsWith("files/")) return; + try { + const url = parseAndValidatePublicUrl(value); + if (url.protocol !== "https:") { + context.addIssue({ code: "custom", message: "media URLs must use HTTPS" }); + } + } catch { + context.addIssue({ code: "custom", message: "media URL must be a safe public HTTPS URL" }); + } + }); + +const geminiFileDataSchema = z + .object({ + mime_type: z.string().trim().min(1).max(MAX_MEDIA_TYPE_LENGTH).optional(), + mimeType: z.string().trim().min(1).max(MAX_MEDIA_TYPE_LENGTH).optional(), + file_uri: geminiFileUriSchema.optional(), + fileUri: geminiFileUriSchema.optional(), + }) + .passthrough() + .refine((value) => Boolean(value.file_uri || value.fileUri), { + message: "Gemini file_data requires file_uri", + }); + +export const geminiNativePartSchema = z + .object({ + text: z.string().trim().min(1).max(MAX_EMBEDDING_TEXT_LENGTH).optional(), + inline_data: geminiInlineBlobSchema.optional(), + inlineData: geminiInlineBlobSchema.optional(), + file_data: geminiFileDataSchema.optional(), + fileData: geminiFileDataSchema.optional(), + }) + .passthrough() + .refine((value) => isGeminiNativeEmbeddingItem(value) && !("parts" in value) && !("content" in value), { + message: "Gemini part must be { text }, { inline_data }, or { file_data }", + }); + +export const geminiNativeContentSchema = z + .object({ + parts: z.array(geminiNativePartSchema).min(1, "parts must contain at least one part"), + }) + .passthrough(); + +export const geminiNativeEmbedRequestSchema = z + .object({ + content: geminiNativeContentSchema, + }) + .passthrough(); + +export const geminiNativeItemSchema = z.union([ + geminiNativePartSchema, + geminiNativeContentSchema, + geminiNativeEmbedRequestSchema, +]); + +const jinaNativeOrCanonicalArraySchema = z + .array( + z.union([ + nonEmptyStringSchema, + embeddingMultimodalItemSchema, + jinaNativeDocSchema, + jinaMergedContentGroupSchema, + geminiNativeItemSchema, + ]) + ) + .min(1, "input must contain at least one item") + .max(MAX_EMBEDDING_INPUT_ITEMS, `input must contain at most ${MAX_EMBEDDING_INPUT_ITEMS} items`) + .superRefine((items, context) => { + const totalBytes = items.reduce( + (total, item) => total + decodedInlineBytesFromEmbeddingItem(item), + 0 + ); if (totalBytes > MAX_EMBEDDING_INLINE_TOTAL_BYTES) { context.addIssue({ code: "custom", @@ -133,6 +383,10 @@ export const embeddingInputSchema = z.union([ embeddingTokenArraySchema, z.array(embeddingTokenArraySchema).min(1, "input must contain at least one item"), embeddingMultimodalInputSchema, + jinaNativeDocSchema, + jinaMergedContentGroupSchema, + geminiNativeItemSchema, + jinaNativeOrCanonicalArraySchema, ]); export type EmbeddingMultimodalItem = z.infer; @@ -244,6 +498,30 @@ export const v1RerankSchema = z }) .catchall(z.unknown()); +// POST /v1/classify — Jina zero/few-shot classification (api.jina.ai). +export const v1ClassifySchema = z + .object({ + model: modelIdSchema.optional(), + classifier_id: z.string().trim().min(1).optional(), + input: z.union([ + nonEmptyStringSchema, + z.array(z.unknown()).min(1, "input must contain at least one item"), + ]), + labels: z.array(z.string().trim().min(1)).min(1).optional(), + }) + .catchall(z.unknown()); + +// POST /v1/segment — Jina segmenter (segment.jina.ai). +export const v1SegmentSchema = z + .object({ + content: nonEmptyStringSchema, + tokenizer: z.string().trim().min(1).optional(), + return_tokens: z.boolean().optional(), + return_chunks: z.boolean().optional(), + max_chunk_length: z.coerce.number().positive().optional(), + }) + .catchall(z.unknown()); + export const providerChatCompletionSchema = z .object({ model: modelIdSchema, @@ -305,6 +583,9 @@ export const v1SearchSchema = z "youcom-search", "searxng-search", "zai-search", + "jina-search", + "jina-ai", + "jina", "duckduckgo-free", ]) .optional(), diff --git a/src/shared/validation/schemas/keys.ts b/src/shared/validation/schemas/keys.ts index 04a2f2d4e4..56875c3c1d 100644 --- a/src/shared/validation/schemas/keys.ts +++ b/src/shared/validation/schemas/keys.ts @@ -18,16 +18,30 @@ import { accessScheduleSchema } from "./misc.ts"; // ──── API Key Schemas ──── -export const createKeySchema = z.object({ - name: z.string().min(1, "Name is required").max(200), - noLog: z.boolean().optional(), - allowUsageCommand: z.boolean().optional(), - usageLimitEnabled: z.boolean().optional(), - dailyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), - weeklyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), - chaosModeEnabled: z.boolean().optional(), - scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(), -}); +const requireExclusiveLeaseConnections = (value: { + scopes?: string[]; allowedConnections?: string[]; +}, ctx: z.RefinementCtx) => { + if (value.scopes?.includes("lease:exclusive") && !value.allowedConnections?.length) + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "lease:exclusive requires explicit allowedConnections", + path: ["allowedConnections"], + }); +}; + +export const createKeySchema = z + .object({ + name: z.string().min(1, "Name is required").max(200), + noLog: z.boolean().optional(), + allowUsageCommand: z.boolean().optional(), + usageLimitEnabled: z.boolean().optional(), + dailyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), + weeklyUsageLimitUsd: z.coerce.number().min(0).optional().nullable(), + chaosModeEnabled: z.boolean().optional(), + scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(), + allowedConnections: z.array(z.string().uuid()).min(1).max(100).optional(), + }) + .superRefine(requireExclusiveLeaseConnections); export const createSyncTokenSchema = z.object({ name: z.string().trim().min(1, "Name is required").max(200), @@ -157,4 +171,7 @@ export const updateKeyPermissionsSchema = z path: [], }); } + if (value.scopes !== undefined && value.allowedConnections !== undefined) { + requireExclusiveLeaseConnections(value, ctx); + } }); diff --git a/src/shared/validation/schemas/settings.ts b/src/shared/validation/schemas/settings.ts index a8d158cf73..06ae7ebef6 100644 --- a/src/shared/validation/schemas/settings.ts +++ b/src/shared/validation/schemas/settings.ts @@ -11,6 +11,7 @@ import { isForbiddenCustomHeaderName, } from "@/shared/constants/upstreamHeaders"; import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts"; +import { AUTO_DISABLE_BANNED_SCOPES } from "@/shared/utils/autoDisableBanned"; // Single source of truth: ../settingsSchemas (the schema the runtime settings route validates // against). Re-exported here so this modular barrel stays in exact lockstep — a divergent local @@ -280,5 +281,6 @@ export const updateAutoDisableAccountsSchema = z .object({ enabled: z.boolean(), threshold: z.number().int().min(1).max(10).optional(), + scope: z.enum(AUTO_DISABLE_BANNED_SCOPES).optional(), }) .strict(); diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 7c00037c87..c4cb849573 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -363,6 +363,7 @@ export const updateSettingsSchema = z.object({ modalityBridgeVideoEnabled: z.boolean().optional(), modalityBridgeVideoModel: z.string().max(200).optional(), modalityBridgeVideoFrameCount: z.number().int().min(1).max(16).optional(), + modalityBridgeVideoSamplingPolicy: z.enum(["uniform", "scene_aware", "segment_aware"]).optional(), modalityBridgeVideoMaxVideos: z.number().int().min(1).max(4).optional(), modalityBridgeVideoTimeout: z .number() diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 6362433cd8..b8aff4cd2a 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -32,11 +32,17 @@ import { getImageModelEntry } from "@omniroute/open-sse/config/imageRegistry.ts" import { acceptHeaderForcesStream } from "@omniroute/open-sse/utils/aiSdkCompat.ts"; import { applyNoThinkingAlias } from "@omniroute/open-sse/utils/noThinkingAlias.ts"; import { resolveCcDiscoveryAliasStrip } from "@/lib/ccDiscoveryAliasResolve"; -import { handleComboChat, shouldSkipConnDisable } from "@omniroute/open-sse/services/combo.ts"; -import type { SingleModelTarget } from "@omniroute/open-sse/services/combo/types.ts"; +import { + handleComboChat, + resolveComboTargets, + shouldSkipConnDisable, +} from "@omniroute/open-sse/services/combo.ts"; +import type { ComboLike, SingleModelTarget } from "@omniroute/open-sse/services/combo/types.ts"; import { mergeAbortSignals } from "@omniroute/open-sse/executors/base.ts"; import { resolveRequestAutoControls } from "@omniroute/open-sse/services/autoCombo/requestControls.ts"; import { isVerifiedNativeCodexRequest } from "@omniroute/open-sse/config/codexIdentity.ts"; +import { resolveCompressionSettings } from "@omniroute/open-sse/handlers/chatCore/compressionSettings.ts"; +import type { CompressionExclusions } from "@omniroute/open-sse/services/compression/exclusions.ts"; import { resolveComboConfig } from "@omniroute/open-sse/services/comboConfig.ts"; import { injectHandoffIntoBody } from "@omniroute/open-sse/services/contextHandoff.ts"; import { @@ -89,8 +95,10 @@ import { withSelectedConnectionHeader, withCorrelationId, withModalityBridgeHeader, + withConversationId, } from "./chatHelpers"; import { buildModalityBridgeHeader } from "@/lib/guardrails/modalityBridge/bridgeStats"; +import { resolveConversationId } from "@omniroute/open-sse/services/conversationTracker.ts"; import { isAntigravityMissingProjectError, isProviderBreakerFailureStatus, @@ -117,6 +125,7 @@ import { getComboFailureLogError } from "./comboFailureLogging"; import { classify429FromError, type FailureKind } from "@/shared/utils/classify429"; import { isSubscriptionQuotaText } from "@omniroute/open-sse/services/quotaTextCooldowns.ts"; import { resolveUseUpstream429BreakerHints } from "@/shared/utils/providerHints"; +import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { getCircuitBreaker, isLocalStreamLifecycleError } from "../../shared/utils/circuitBreaker"; import { markAccountExhaustedFrom429 } from "../../domain/quotaCache"; import { resolveForcedConnectionForCredentialPool } from "../services/sessionAffinityPin.ts"; @@ -169,6 +178,16 @@ import { } from "../services/cooldownAwareRetry"; import { constrainConnectionsToQuota, resolveQuotaKeyScope } from "../../lib/quota/quotaKey"; import { checkConnectionCapacity } from "../utils/backpressure"; +import { + buildManagedLeaseErrorResponse, + buildManagedLeaseSelectionErrorResponse, + credentialLease, + isExclusiveLeaseManagedKey, + LeaseContextError, + parseManagedLeaseRequestContext, + validateExclusiveLeaseKeyConfiguration, + type ManagedLeaseDispatchContext, +} from "../services/leaseContext"; registerCodexQuotaFetcher(); @@ -209,12 +228,37 @@ registerGrokWebQuotaFetcher(); // what lets the per-window cutoff modal in Dashboard › Limits actually // enforce thresholds for Claude / GLM / Cursor / etc., not just Codex. registerGenericQuotaFetchers(); -let combosCachePromise: Promise | null = null; +let combosCachePromise: Promise | null = null; let combosCacheTs = 0; let combosCacheVersionSnapshot = -1; const COMBOS_CACHE_TTL_MS = 10_000; -async function getCombosCachedForChat(): Promise { +/** + * #10225 — resolve whether this request's combo preflight should DEFER its hard + * context-overflow rejection so chatCore's compression runs first. + * + * Mirrors handleChatCore's own enablement determination (chatCore.ts): defer only + * when the global compression switch is ON and the API key has not opted out + * (`apiKeyInfo.compressionEnabled !== false`). Per-target applicability (server-side + * exclusions) is checked inside getKnownContextOverflow via the returned exclusions. + * Fail closed (defer=false) on any lookup error — the existing hard preflight stays. + */ +async function resolveComboContextOverflowDeferral( + logger: { warn?: (...args: unknown[]) => void } | null | undefined, + apiKeyInfo: { compressionEnabled?: boolean } | null | undefined +): Promise<{ defer: boolean; exclusions: CompressionExclusions | undefined }> { + try { + const compression = await resolveCompressionSettings(logger); + return { + defer: compression.enabled && apiKeyInfo?.compressionEnabled !== false, + exclusions: compression.settings?.exclusions, + }; + } catch { + return { defer: false, exclusions: undefined }; + } +} + +async function getCombosCachedForChat(): Promise { const now = Date.now(); // Explicit non-null check: we intentionally cache and return the Promise // itself (to dedupe concurrent callers), so this is not a forgotten await. @@ -231,7 +275,7 @@ async function getCombosCachedForChat(): Promise { combosCacheTs = now; combosCacheVersionSnapshot = getCombosCacheVersion(); - combosCachePromise = getCombos().catch(() => []); + combosCachePromise = getCombos().catch(() => []) as Promise; return combosCachePromise; } @@ -254,6 +298,44 @@ function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): st return first || second || null; } +function isManagedComboUnsupported( + combo: ComboLike, + settings: Record, + allCombos: ComboLike[], + visited = new Set() +): boolean { + if (visited.has(combo.name)) return false; + visited.add(combo.name); + const strategy = combo.strategy ?? "priority"; + const config = resolveComboConfig(combo, settings) as Record; + const resolvedTargets = resolveComboTargets(combo, allCombos); + const pipeline = + strategy === "pipeline" || + (strategy === "auto" && (config.pipeline_enabled === true || combo.name === "auto/smart")); + const nestedUnsafe = (combo.models as Array<{ kind?: string; comboName?: string }>).some( + (step) => { + if (step?.kind !== "combo-ref" || !step.comboName) return false; + const nested = allCombos.find((candidate) => candidate.name === step.comboName); + return Boolean(nested && isManagedComboUnsupported(nested, settings, allCombos, visited)); + } + ); + return ( + strategy === "fusion" || + strategy === "context-relay" || + (config.chaos as { enabled?: boolean } | undefined)?.enabled === true || + (config.shadowRouting as { enabled?: boolean } | undefined)?.enabled === true || + (config.zeroLatencyOptimizationsEnabled === true && config.hedging === true) || + (resolvedTargets.length > 1 && + (pipeline || resolvedTargets.some((target) => Boolean(target.connectionId?.trim())))) || + nestedUnsafe + ); +} + +const managedComboRejection = () => + buildManagedLeaseErrorResponse( + new LeaseContextError(409, "LEASE_UNSUPPORTED_ROUTE", "Managed leases do not support this route") + ); + const comboPromoteDeps = { updateCombo, info: log.info, warn: log.warn }; export { shouldTripProviderBreakerForResult } from "./chatPredicates"; @@ -518,6 +600,19 @@ async function handleChatImplementation( return policy.rejection; } const apiKeyInfo = policy.apiKeyInfo; + let managedLease: ManagedLeaseDispatchContext | null = null; + if (isExclusiveLeaseManagedKey(apiKeyInfo)) { + try { + validateExclusiveLeaseKeyConfiguration(apiKeyInfo); + managedLease = { + apiKeyId: apiKeyInfo!.id, + context: parseManagedLeaseRequestContext(request.headers), + }; + } catch (error) { + if (error instanceof LeaseContextError) return buildManagedLeaseErrorResponse(error); + throw error; + } + } const bypassProviderQuotaPolicy = hasProviderQuotaBypassScope(apiKeyInfo?.scopes); telemetry.endPhase(); @@ -633,6 +728,30 @@ async function handleChatImplementation( const modalityBridgeHeader = buildModalityBridgeHeader(preCallGuardrails.results); telemetry.endPhase(); + // Agentic conversation tracking (X-ConversationId): resolved once per + // incoming HTTP request, before combo dispatch / credential retries, so + // every attempt for this request shares the same id and the + // agentic_conversations row is only touched once. + const clientConversationHeader = request.headers.get("x-omniroute-session-id")?.trim() || null; + let conversationId: string | null = null; + try { + ({ conversationId } = await resolveConversationId({ + body: body as Record, + model: modelStr, + apiKeyId: apiKeyInfo?.id ?? null, + clientSessionIdHeader: clientConversationHeader, + correlationId: reqId, + })); + } catch (error) { + // Best-effort tracking: a DB hiccup here must not turn an otherwise-working + // chat request into a hard failure. Downstream conversationId consumers + // already treat null/undefined as "untracked" (see withConversationId). + log.warn("CHAT", "resolveConversationId failed, continuing without conversation tracking", { + correlationId: reqId, + error: error instanceof Error ? error.message : String(error), + }); + } + // T08: per-key active session limit (0 = unlimited). if (apiKeyInfo?.id && sessionId) { const maxSessions = @@ -771,6 +890,12 @@ async function handleChatImplementation( if (filtered instanceof Response) return filtered; combo = filtered; } + const [settings, allCombos] = await Promise.all([ + getCachedSettings().catch(() => ({})), + getCombosCachedForChat(), + ]); + if (managedLease && isManagedComboUnsupported(combo, settings, allCombos)) + return managedComboRejection(); log.info( "CHAT", `Combo "${modelStr}" [${combo.strategy || "priority"}] with ${combo.models.length} models` @@ -857,24 +982,25 @@ async function handleChatImplementation( ...(target?.allowRateLimitedConnection ? { allowRateLimitedConnections: true } : {}), ...(target?.connectionId ? { forcedConnectionId: target.connectionId } : {}), ...(bypassProviderQuotaPolicy ? { bypassQuotaPolicy: true } : {}), + ...(managedLease ? { lease: credentialLease(managedLease) } : {}), } ); - if (!creds || !("authType" in creds)) return false; + if ( + !creds || + ("allRateLimited" in creds && creds.allRateLimited) || + ("waitingForCapacity" in creds && creds.waitingForCapacity) + ) + return false; // OAuth selection must happen atomically with occupancy reservation in the // actual dispatch. Availability preflight may finish well before a combo // target runs, so caching OAuth credentials here would reintroduce a race. - if (creds.authType !== "oauth") { + if ("authType" in creds && creds.authType !== "oauth") { comboPreselectedCredentials.set(getComboCredentialCacheKey(modelString, target), creds); } return true; }; - // Fetch settings and all combos for config cascade and nested resolution - const [settings, allCombos] = await Promise.all([ - getCachedSettings().catch(() => ({})), - getCombosCachedForChat(), - ]); const relayConfig = combo.strategy === "context-relay" ? resolveComboConfig(combo, settings) : null; // Per-request Auto-Combo controls (#6023 / #6024 / #6025 / #3470): steer an @@ -890,9 +1016,20 @@ async function handleChatImplementation( // Context-relay keeps generation in combo.ts, but handoff injection lives here // because only this layer knows which connectionId was actually selected. + const { defer: deferContextOverflowWhenCompressible, exclusions: compressionExclusions } = + await resolveComboContextOverflowDeferral(log, apiKeyInfo); const response = await (handleComboChat as any)({ body, combo, + deferContextOverflowWhenCompressible, + compressionExclusions, + // #10503: same request-shape facts chatCore.ts resolves for itself + // (resolveChatCoreRequestFormat), so getKnownContextOverflow's target-aware + // deferral check can never drift from chatCore's own native-codex-passthrough + // decision. See knownContextOverflow.ts::KnownContextOverflowOptions. + sourceFormat, + endpointPath: new URL(request.url).pathname, + requestHeaders: request.headers, clientManagedResponsesContext: sourceFormat === "openai-responses" && new URL(request.url).pathname.split("/").includes("responses") && @@ -939,10 +1076,12 @@ async function handleChatImplementation( cachedSettings: settings, providerId: target?.providerId ?? null, correlationId: reqId, + conversationId, modelPinned: (target as any)?.modelPinned ?? false, reasoningDecision, reasoningIntent, reasoningRequestTags: requestRoutingTags.tags, + managedLease, // #7360 follow-up: without this, a target dispatch abandoned by // targetTimeoutRunner.ts's per-target timeout (comboTargetTimeoutMs) // never learns it was abandoned — it only watches the ORIGINAL @@ -975,6 +1114,8 @@ async function handleChatImplementation( relayOptions, signal: request?.signal ?? null, correlationId: reqId, + // #9654 Wave 2: per-target lane-aware admission probe for combo fan-out. + perTargetAdmission: admissionContext.createPerTargetAdmissionHook(apiKeyInfo?.id, request), }); for (const credentials of comboPreselectedCredentials.values()) { @@ -1009,6 +1150,8 @@ async function handleChatImplementation( sessionAffinityKey, emergencyFallbackTried: true, forceLiveComboTest: isComboLiveTest, + conversationId, + managedLease, }, combo.strategy, true @@ -1017,7 +1160,7 @@ async function handleChatImplementation( log.info("GLOBAL_FALLBACK", `Global fallback ${fallbackModel} succeeded`); recordTelemetry(telemetry); return withModalityBridgeHeader( - withSessionHeader(fallbackResponse, sessionId), + withConversationId(withSessionHeader(fallbackResponse, sessionId), conversationId), modalityBridgeHeader ); } @@ -1051,13 +1194,17 @@ async function handleChatImplementation( apiKeyId: apiKeyInfo?.id ?? null, apiKeyName: apiKeyInfo?.name ?? null, correlationId: reqId, + sessionTag: conversationId, startTime: telemetry?.startTime, requestBody: clientRawRequest?.body ?? null, }); } catch {} } return withModalityBridgeHeader( - withCorrelationId(withSessionHeader(response, sessionId), reqId), + withConversationId( + withCorrelationId(withSessionHeader(response, sessionId), reqId), + conversationId + ), modalityBridgeHeader ); } @@ -1092,17 +1239,22 @@ async function handleChatImplementation( forceLiveComboTest: isComboLiveTest, forcedConnectionId: requestedConnectionId, correlationId: reqId, + conversationId, routingComboId, reasoningDecision, reasoningIntent, reasoningRequestTags: requestRoutingTags.tags, + managedLease, }, null, false ); recordTelemetry(telemetry); return withModalityBridgeHeader( - withCorrelationId(withSessionHeader(response, sessionId), reqId), + withConversationId( + withCorrelationId(withSessionHeader(response, sessionId), reqId), + conversationId + ), modalityBridgeHeader ); } @@ -1133,11 +1285,13 @@ async function handleSingleModelChat( cachedSettings?: any; providerId?: string | null; correlationId?: string | null; + conversationId?: string | null; routingComboId?: string | null; modelPinned?: boolean; reasoningDecision?: ReasoningRuleDecision | null; reasoningIntent?: ExtractedReasoningIntent | null; reasoningRequestTags?: string[]; + managedLease?: ManagedLeaseDispatchContext | null; /** * Per-target abort signal from combo.ts's targetTimeoutRunner * (comboTargetTimeoutMs) — see the #7360 follow-up comment at the @@ -1163,17 +1317,29 @@ async function handleSingleModelChat( // resolveModelOrError found a combo but the main handler's combo lookup missed it. if ((resolved as any).combo) { const redirectCombo = (resolved as any).combo; + if (runtimeOptions.managedLease) return managedComboRejection(); log.info( "ROUTING", `Safety-net combo redirect for "${modelStr}" → combo="${redirectCombo.name}"` ); log.info("ROUTING", `Auto-combo redirect from handleSingleModelChat for "${modelStr}"`); log.info("ROUTING", `Auto-combo redirect to combo flow for "${modelStr}"`); + const { defer: sNetDefer, exclusions: sNetExclusions } = + await resolveComboContextOverflowDeferral(log, apiKeyInfo); + // #10503: same request-shape facts chatCore.ts resolves for itself — threaded + // down so getKnownContextOverflow's target-aware deferral check can never drift + // from chatCore's own native-codex-passthrough decision. + const sNetSourceFormat = detectFormatFromEndpoint(body, clientRawRequest?.endpoint || ""); return handleComboChat({ body, combo: redirectCombo, + deferContextOverflowWhenCompressible: sNetDefer, + compressionExclusions: sNetExclusions, + sourceFormat: sNetSourceFormat, + endpointPath: clientRawRequest?.endpoint || "", + requestHeaders: clientRawRequest?.headers, clientManagedResponsesContext: - detectFormatFromEndpoint(body, clientRawRequest?.endpoint || "") === "openai-responses" && + sNetSourceFormat === "openai-responses" && String(clientRawRequest?.endpoint || "") .split("/") .includes("responses") && @@ -1199,6 +1365,8 @@ async function handleSingleModelChat( allowRateLimitedConnection: resolvedTarget?.allowRateLimitedConnection === true, providerId: resolvedTarget?.providerId ?? null, correlationId: runtimeOptions?.correlationId ?? null, + conversationId: runtimeOptions?.conversationId ?? null, + managedLease: runtimeOptions.managedLease ?? null, // #7360 follow-up — see the primary handleSingleModel closure above. modelAbortSignal: target?.modelAbortSignal ?? null, }, @@ -1212,6 +1380,11 @@ async function handleSingleModelChat( allCombos: [], relayOptions: undefined, signal: request?.signal ?? null, + // #9654 Wave 2: safety-net redirect — same per-target probe as the primary path. + perTargetAdmission: chatAdmission.createPerTargetAdmissionHookForRequest( + apiKeyInfo?.id, + request + ), }); } @@ -1296,6 +1469,7 @@ async function handleSingleModelChat( apiKeyId: apiKeyInfo?.id ?? null, apiKeyName: apiKeyInfo?.name ?? null, correlationId: runtimeOptions?.correlationId ?? null, + sessionTag: runtimeOptions?.conversationId ?? null, startTime: telemetry?.startTime, }); } catch {} @@ -1397,6 +1571,9 @@ async function handleSingleModelChat( ...(!forceLiveComboTest && bypassProviderQuotaPolicy ? { bypassQuotaPolicy: true } : {}), + ...(runtimeOptions.managedLease + ? { lease: credentialLease(runtimeOptions.managedLease) } + : {}), ...(() => { const effectiveForcedId = resolveForcedConnectionForCredentialPool({ forcedConnectionId: runtimeOptions.forcedConnectionId ?? null, @@ -1414,6 +1591,11 @@ async function handleSingleModelChat( ); preselectedCredentials = null; + if (runtimeOptions.managedLease && credentials) { + const leaseError = buildManagedLeaseSelectionErrorResponse(credentials); + if (leaseError) return leaseError; + } + // #9467: also treat the auth layer's allExpired verdict as a no-credentials // outcome (auth.ts produces it; without this check an all-expired pool fell // through to a connectionless dispatch). @@ -1502,7 +1684,22 @@ async function handleSingleModelChat( const accountId = credentials.connectionId.slice(0, 8); const releaseOAuthSession = credentials.releaseOAuthSession ?? (() => {}); - log.info("AUTH", `Using ${provider} account: ${accountId}...`); + // #10348: redact the account prefix by default. Gated on the narrow + // AUTH_LOG_INCLUDE_ACCOUNT_ID flag (default off) rather than the broad + // `debugMode` setting — `debugMode` is a general dashboard-visibility + // toggle unrelated to log privacy (its own default has changed + // independently for unrelated reasons, see #10312/#10372), so deriving + // redaction from it would make log leakage depend on an unrelated + // setting. resolveFeatureFlag() reads straight from SQLite on every + // call (no stale cache to invalidate) and fails safe (redacted) if the + // lookup throws. + let includeAccountId = false; + try { + includeAccountId = isFeatureFlagEnabled("AUTH_LOG_INCLUDE_ACCOUNT_ID"); + } catch { + includeAccountId = false; + } + log.info("AUTH", `Using ${provider} account: ${includeAccountId ? accountId : "***"}...`); // #474: when the request used a bare model name (no "/" — e.g. an alias // that resolved to "auto") and the selected connection declares a // defaultModel, resolve the bare name to that real model ID before the @@ -1629,9 +1826,11 @@ async function handleSingleModelChat( cachedSettings: runtimeOptions.cachedSettings, skipUpstreamRetry: runtimeOptions.skipUpstreamRetry ?? false, correlationId: runtimeOptions?.correlationId ?? null, + conversationId: runtimeOptions?.conversationId ?? null, modelPinned: runtimeOptions?.modelPinned ?? false, routingComboId: runtimeOptions?.routingComboId ?? null, sessionAffinityKey: runtimeOptions.sessionAffinityKey ?? null, + managedLease: runtimeOptions.managedLease ?? null, }, runtimeOptions ); @@ -1686,6 +1885,16 @@ async function handleSingleModelChat( return result.response; } + // A final hard-lease fence rejection is authoritative. It must never mutate + // connection health/cooldown state or fall through to ordinary account/model + // fallback, which could turn a stale lifecycle into unmanaged dispatch. + if ( + runtimeOptions.managedLease && + (result.errorType === "lease_error" || String(result.errorCode || "").startsWith("LEASE_")) + ) { + return result.response; + } + // Missing Cloud Code project assignment is configuration, not a transient failure. // Preserve the typed fail-closed 422; marking it unavailable would trigger cooldown // redispatch and repeat bootstrap within the same logical request. diff --git a/src/sse/handlers/chat/clientRawRequest.ts b/src/sse/handlers/chat/clientRawRequest.ts index 0fd80cd9a8..f0dc47667d 100644 --- a/src/sse/handlers/chat/clientRawRequest.ts +++ b/src/sse/handlers/chat/clientRawRequest.ts @@ -13,6 +13,9 @@ import { cloneBoundedForLog } from "@omniroute/open-sse/utils/requestLogger.ts"; export function buildClientRawRequest(request: Request, body: unknown) { const url = new URL(request.url); + const headers = Object.fromEntries(request.headers.entries()); + delete headers["x-omniroute-lease-owner"]; + delete headers["x-omniroute-lease-generation"]; return { endpoint: url.pathname, // #7847: bounded, not a full deep clone. Every consumer of clientRawRequest.body is @@ -24,7 +27,7 @@ export function buildClientRawRequest(request: Request, body: unknown) { // Still a clone, not an alias — `body` is rewritten downstream (plugin onRequest hook, // compression), and this has to stay a snapshot of what the client actually sent. body: cloneBoundedForLog(body), - headers: Object.fromEntries(request.headers.entries()), + headers, signal: request.signal ?? null, }; } diff --git a/src/sse/handlers/chatAdmission.ts b/src/sse/handlers/chatAdmission.ts index 78387e2742..37a51027a9 100644 --- a/src/sse/handlers/chatAdmission.ts +++ b/src/sse/handlers/chatAdmission.ts @@ -12,6 +12,7 @@ import { type AdaptiveAdmissionFailureOutcome, type AdaptiveAdmissionRuntime, } from "@omniroute/open-sse/services/admission/runtime.ts"; +import type { PerTargetAdmissionHook } from "@omniroute/open-sse/services/admission/types.ts"; /** Single fairness bucket for unauthenticated / keyless traffic. Opaque; never a raw key. */ export const ANONYMOUS_ADMISSION_TENANT_KEY = "anonymous"; @@ -26,8 +27,101 @@ export type ChatAdmissionContext = { request: { signal?: AbortSignal | null }, body: unknown ): Promise; + /** + * #9654 Wave 2: build a per-target lane-aware admission probe for combo / + * fusion fan-out dispatch. Strictly non-blocking (maxWaitMs 0 — skip, never + * queue), a no-op when virtual lanes are off, and keyed to the PARENT's + * tenantKey so it gates the same per-tenant lane as this request's lease. + */ + createPerTargetAdmissionHook( + apiKeyId: string | null | undefined, + request: { signal?: AbortSignal | null } + ): PerTargetAdmissionHook; }; +/** + * #9654 Wave 2: per-target fan-out admission probe. + * + * Combo and fusion dispatch N targets without consulting the adaptive-admission + * layer — the parent request holds one lease, but every fan-out target is + * dispatched unconditionally. With virtual lanes on, a tenant whose lane is + * full should SKIP additional fan-out targets instead of piling more queued work + * onto an already-congested lane. + * + * Probe semantics: + * - strictly non-blocking: maxWaitMs 0 — if the lane can't admit right now, + * the target is skipped, never queued; + * - release-on-admit: the probe is a capacity gate, not a hold — the parent's + * lease covers the fan-out, so the probe lease is released immediately; + * - lanes-off no-op: the shared queue is the only gate, and the parent already + * holds one lease there — probing would double-count and reject combo targets. + */ +function createPerTargetAdmissionHookImpl( + runtime: AdaptiveAdmissionRuntime, + tenantKey: string, + signal?: AbortSignal | null +): PerTargetAdmissionHook { + // Lanes-off no-op: never probe the shared queue for fan-out targets (the + // parent request already holds the one lease that matters there). The flag + // comes from startup config, so read it once here — building a snapshot per + // fan-out target would be pure overhead on the default (lanes-off) path. + const lanesEnabled = runtime.snapshot().virtualLanes === true; + + return async (target) => { + if (!lanesEnabled) return true; + + try { + const streaming = + target.body !== null && + typeof target.body === "object" && + (target.body as { stream?: unknown }).stream === true; + + const result = await runtime.acquire({ + tenantKey, + body: target.body, + signal: signal ?? undefined, + // Price the class of the request the target will actually dispatch: fusion + // panel bodies carry stream:false (non-streaming class), priority/RR carry + // the user's flag. Without this the probe would under-estimate cost and + // admit more fan-out targets than the lane can truly afford (#9654 Q4). + streaming, + maxWaitMs: 0, + }); + if (result.status === "admitted") { + // Capacity gate only — release the probe lease immediately. + result.lease.release("success"); + return true; + } + return false; + } catch { + // Fail-open: admission is a capacity gate, not the source of truth. A + // hiccup in the admission layer must not take down the fan-out — the + // target simply dispatches ungated. + return true; + } + }; +} + +/** Public factory — build a probe against an explicit runtime (test seam). */ +export const createPerTargetAdmissionHook = createPerTargetAdmissionHookImpl; + +/** + * Module-level convenience for paths without a ChatAdmissionContext in scope + * (e.g. the safety-net combo redirect inside handleSingleModelChat). Resolves + * the process-global runtime + tenant key from the API key id, like the + * context method does. + */ +export function createPerTargetAdmissionHookForRequest( + apiKeyId: string | null | undefined, + request: { signal?: AbortSignal | null } +): PerTargetAdmissionHook { + return createPerTargetAdmissionHookImpl( + getAdaptiveAdmissionRuntime(), + resolveAdmissionTenantKey(apiKeyId), + request?.signal ?? null + ); +} + type AdmittedState = { runtime: AdaptiveAdmissionRuntime; admitted: AdaptiveAdmissionAdmitted; @@ -175,6 +269,13 @@ export function createChatAdmissionContext( state = { runtime, admitted: result }; return null; }, + createPerTargetAdmissionHook(apiKeyId, request) { + return createPerTargetAdmissionHookImpl( + getRuntime(), + resolveAdmissionTenantKey(apiKeyId), + request?.signal ?? null + ); + }, }; } diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index c554cfe856..c82333a78e 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -419,9 +419,11 @@ export async function executeChatWithBreaker({ skipUpstreamRetry = false, trafficType = "production", correlationId = null, + conversationId = null, modelPinned = false, routingComboId = null, sessionAffinityKey = null, + managedLease = null, }: ExecuteChatWithBreakerOptions): Promise { let tlsFingerprintUsed = false; const normalizedTrafficType: TrafficType = @@ -475,9 +477,11 @@ export async function executeChatWithBreaker({ skipUpstreamRetry, trafficType: normalizedTrafficType, correlationId, + conversationId, modelPinned, routingComboId, sessionAffinityKey, + managedLease, skipResourcePressureGuard: true, onCredentialsRefreshed: async (newCreds: any) => { await updateProviderCredentials(credentials.connectionId, { @@ -953,6 +957,23 @@ export function withModalityBridgeHeader(response: Response, value: string | nul } } +export function withConversationId(response: Response, conversationId: string | null): Response { + if (!response || !conversationId) return response; + + try { + response.headers.set("X-ConversationId", conversationId); + return response; + } catch { + const cloned = new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + cloned.headers.set("X-ConversationId", conversationId); + return cloned; + } +} + export function withSelectedConnectionHeader( response: Response, connectionId: string | null | undefined diff --git a/src/sse/handlers/rejectedRequestUsage.ts b/src/sse/handlers/rejectedRequestUsage.ts index 46b8099014..8a817393de 100644 --- a/src/sse/handlers/rejectedRequestUsage.ts +++ b/src/sse/handlers/rejectedRequestUsage.ts @@ -30,6 +30,8 @@ export interface RejectedRequestUsageInput { comboStepId?: string | null; comboExecutionKey?: string | null; correlationId?: string | null; + /** Conversation id (X-ConversationId) — see open-sse/services/conversationTracker.ts. */ + sessionTag?: string | null; apiKeyId?: string | null; apiKeyName?: string | null; connectionId?: string | null; @@ -56,6 +58,7 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu comboStepId = null, comboExecutionKey = null, correlationId = null, + sessionTag = null, apiKeyId = null, apiKeyName = null, connectionId = undefined, @@ -86,6 +89,7 @@ export async function recordRejectedRequestUsage(input: RejectedRequestUsageInpu apiKeyId, apiKeyName, correlationId, + sessionTag, }).catch(() => {}); // 2. usage_history — so the per-api-key usage counter reflects rejected diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index da70522843..bf9076df92 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1,4 +1,5 @@ import { randomUUID, createHash } from "crypto"; +import { nodeTypeFromId } from "@/lib/db/providerNodeSelect"; import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts"; import { getCachedRawProviderConnections, @@ -13,7 +14,14 @@ import { clearConnectionErrorIfUnchanged, } from "@/lib/db/providers"; import { validateApiKey } from "@/lib/db/apiKeys"; +import { + getActiveExclusiveConnectionLease, + hashLeaseOwnerId, + type ExclusiveConnectionLease, +} from "@/lib/db/exclusiveConnectionLeases"; import { getSettings } from "@/lib/db/settings"; +import { buildJinaEnvCredentials } from "@/lib/providers/jina"; +import { buildGeminiEnvCredentials } from "@/lib/providers/gemini"; import { toNumber } from "@/shared/utils/numeric"; import { createLazyConnectionView, @@ -42,6 +50,7 @@ import { hasPerModelQuota, getRuntimeProviderProfile, recordModelLockoutFailure, + isProviderModelUnsupported400, } from "@omniroute/open-sse/services/accountFallback.ts"; import { isLocalProvider } from "@omniroute/open-sse/config/providerRegistry.ts"; import { COOLDOWN_MS, RateLimitReason } from "@omniroute/open-sse/config/constants.ts"; @@ -88,6 +97,7 @@ import { resolveForcedConnectionForCredentialPool, resolveSessionAffinityTtlMs, selectSessionAffinityConnection, + planSessionAffinityConnection, syncSessionAffinityRuntimeFields, } from "./sessionAffinityPin"; import { @@ -96,10 +106,22 @@ import { } from "./noAuthProviderSettings"; import { resolveAccountProxiesFromRegistry } from "./noAuthProxyResolution"; import { getNoAuthHydrationProviderIds } from "./noAuthProviderSiblings"; +import { loadOptionalNoAuthApiKeyCredentials } from "./noAuthOptionalApiKey"; import { getResource404Bypass } from "./requestResourceHealth"; import { isVertexConnectionWidePermissionDenied } from "./vertexErrorClassifier"; +import { maybeAutoDisableBannedAccount } from "./autoDisableBannedAccount"; import * as log from "../utils/logger"; -import { fisherYatesShuffle, getNextFromDeckSync } from "@/shared/utils/shuffleDeck"; +import { + fisherYatesShuffle, + getNextFromDeckSync, + planNextFromDeckSync, +} from "@/shared/utils/shuffleDeck"; +import { + applyExclusiveConnectionLeasePolicy, + invalidateManagedConnectionLease, + mutateExclusiveConnectionLease, + type CredentialLeaseSelectionContext, +} from "./exclusiveConnectionLeasePolicy"; import { readHeaderValue, type AuthRequestHeaders } from "./headerReader.ts"; import { getOAuthSessionAvailability, @@ -116,7 +138,7 @@ interface RecoverableConnectionState { lastErrorType?: string | null; lastErrorSource?: string | null; } -interface CredentialSelectionOptions { +export interface CredentialSelectionOptions { allowSuppressedConnections?: boolean; allowRateLimitedConnections?: boolean; bypassQuotaPolicy?: boolean; @@ -125,7 +147,19 @@ interface CredentialSelectionOptions { sessionKey?: string | null; sessionAffinityTtlMs?: number | null; reserveOAuthSession?: boolean; + lease?: CredentialLeaseSelectionContext; + materializeCredentials?: boolean; + deferLeaseClaim?: boolean; + /** Internal: a same-call UNIQUE retry already holds the provider/owner selection lock. */ + _leaseRetryWithLockHeld?: boolean; + /** Internal: freeze the original policy-valid candidate set across lease race/preflight retry. */ + _leaseCandidateIds?: string[]; } +export type ExclusiveLeaseSelectionResult = { + exclusiveLease: ExclusiveConnectionLease; + connectionId: string; + provider: string; +}; interface CooldownInspectionState { connection: ProviderConnectionView; connectionCooldownMs: number | null; @@ -927,6 +961,7 @@ function getSelectionMutexKey(provider: string, options: CredentialSelectionOpti return [ resolveProviderId(provider) || provider, options.forcedConnectionId ? `forced:${options.forcedConnectionId}` : "pool", + options.lease ? `lease:${hashLeaseOwnerId(options.lease.context.leaseOwnerId)}` : "unmanaged", ].join(":"); } function createSelectionLock(key: string) { @@ -966,6 +1001,12 @@ const PROVIDER_SEARCH_PAIRS: string[][] = [ // The model layer canonicalizes `agy/` to `antigravity`, but the Antigravity // CLI card stores its connection under `agy`. Same account, either id serves. ["antigravity", "agy"], + // One Jina token works on api.jina.ai, r.jina.ai, and s.jina.ai. + // Requested id stays first so embed/rerank do not silently pick a + // Reader-only row when both cards are filled. jina-search has no + // dashboard card — it must still see jina-ai / jina-reader keys + // before falling through to JINA_AI_API_KEY. + ["jina-ai", "jina-reader", "jina-search"], ]; /** * Resolve provider aliases (e.g., nvidia -> nvidia_nim) for DB lookup @@ -974,8 +1015,8 @@ async function getProviderSearchPool(provider: string): Promise { const canonicalProvider = resolveProviderId(provider); const canonicalAlias = getProviderAlias(canonicalProvider); - const pair = PROVIDER_SEARCH_PAIRS.find((aliases) => aliases.includes(provider)); - if (pair) return pair[0] === provider ? pair : [pair[1], pair[0]]; + const group = PROVIDER_SEARCH_PAIRS.find((aliases) => aliases.includes(provider)); + if (group) return [provider, ...group.filter((id) => id !== provider)]; const searchPool = new Set([provider, canonicalProvider, canonicalAlias].filter(Boolean)); @@ -991,18 +1032,64 @@ async function getProviderSearchPool(provider: string): Promise { // internal provider ids like openai-compatible-responses-. try { const providerNodes = await getCachedProviderNodes(); - for (const node of Array.isArray(providerNodes) ? providerNodes : []) { + const compatibleNodes = Array.isArray(providerNodes) ? providerNodes : []; + const nodeTypes = new Map(); + for (const node of compatibleNodes) { + const nodeRecord = asRecord(node); + const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; + if (!nodeId) continue; + const derivedType = nodeTypeFromId(nodeId); + nodeTypes.set(derivedType, (nodeTypes.get(derivedType) || 0) + 1); + } + + for (const node of compatibleNodes) { const nodeRecord = asRecord(node); const nodePrefix = typeof nodeRecord.prefix === "string" ? nodeRecord.prefix.trim() : ""; const nodeId = typeof nodeRecord.id === "string" ? nodeRecord.id.trim() : ""; - if (!nodePrefix || !nodeId) continue; + if (!nodeId) continue; if ( - nodePrefix === provider || - nodePrefix === canonicalProvider || - nodePrefix === canonicalAlias + nodePrefix && + (nodePrefix === provider || nodePrefix === canonicalProvider || nodePrefix === canonicalAlias) ) { searchPool.add(nodeId); } + + // #10085: bridge the concrete uuid node id (what the chat path resolves, + // "-") to the GENERIC derived type id (what + // resolveProviderNodeForConnection also accepts for connection creation, + // #4421) -- and back. A connection created via the bare generic type + // (e.g. "openai-compatible-chat") must still be found when the chat path + // looks up the concrete node id, and vice versa. + // + // #10434: both bridging directions MUST require the derived type to be + // unambiguous (exactly one provider node of that type) before falling + // back to a generic-type match -- an explicit ownership check, not just + // a string-format coincidence. This mirrors the exact rule already + // enforced by selectProviderNodeForConnection() for connection CREATION + // (src/lib/db/providerNodeSelect.ts, #4421): "only when exactly one such + // node exists, so an ambiguous type never silently picks the wrong + // node". Without this guard on the generic->concrete direction, a bare + // generic-type lookup would pool in EVERY node sharing that derived + // type, including a connection scoped (via its own providerSpecificData + // baseUrl/headers) to one specific node -- leaking that node's + // credentials/upstream URL into a lookup for a different, unrelated + // node of the same generic type. + const derivedType = nodeTypeFromId(nodeId); + if (derivedType && derivedType !== nodeId) { + const typeIsUnambiguous = nodeTypes.get(derivedType) === 1; + if (typeIsUnambiguous) { + if (nodeId === provider || nodeId === canonicalProvider || nodeId === canonicalAlias) { + searchPool.add(derivedType); + } + if ( + derivedType === provider || + derivedType === canonicalProvider || + derivedType === canonicalAlias + ) { + searchPool.add(nodeId); + } + } + } } } catch { // Best-effort alias expansion only. @@ -1011,6 +1098,74 @@ async function getProviderSearchPool(provider: string): Promise { return Array.from(searchPool); } +function invalidateManagedLease( + options: CredentialSelectionOptions, + reason: Parameters[1] +) { + invalidateManagedConnectionLease(options.lease, reason); +} + +type DeferredLeaseSelection = { + commitSelectionSideEffects?: () => Promise | void; + selectNextLeaseCandidate?: (excludedConnectionId: string) => Promise; +}; + +function planLastUsedCommit( + connection: ProviderConnectionView, + connections: ProviderConnectionView[], + count: number +) { + const now = new Date().toISOString(); + return async () => { + await touchConnectionLastUsed(connection.id, count); + connection.lastUsedAt = now; + connection.consecutiveUseCount = count; + syncSessionAffinityRuntimeFields(connections, connection); + }; +} + +function materializeConnection( + connection: ProviderConnectionView, + options: CredentialSelectionOptions, + extra: DeferredLeaseSelection & { exclusiveLease?: ExclusiveConnectionLease } = {} +) { + const apiKeyHealth = connection.providerSpecificData?.apiKeyHealth as + Record | undefined; + if (apiKeyHealth) syncHealthFromDB(connection.id, apiKeyHealth); + const releaseOAuthSession = + options.reserveOAuthSession === true && connection.authType === "oauth" && options.sessionKey + ? reserveOAuthSession(connection.id, options.sessionKey) + : undefined; + return { + apiKey: connection.apiKey, + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + expiresAt: connection.tokenExpiresAt || connection.expiresAt || null, + projectId: connection.projectId, + defaultModel: connection.defaultModel || null, + copilotToken: + typeof connection.providerSpecificData.copilotToken === "string" + ? connection.providerSpecificData.copilotToken + : null, + providerSpecificData: connection.providerSpecificData, + id: connection.id, + provider: connection.provider, + authType: connection.authType, + email: connection.email, + connectionId: connection.id, + testStatus: connection.testStatus, + lastError: connection.lastError, + lastErrorType: connection.lastErrorType, + lastErrorSource: connection.lastErrorSource, + errorCode: connection.errorCode, + rateLimitedUntil: connection.rateLimitedUntil, + maxConcurrent: connection.maxConcurrent, + quotaWindowThresholds: connection.quotaWindowThresholds ?? null, + ...(releaseOAuthSession ? { releaseOAuthSession } : {}), + ...extra, + }; +} + /** * Get provider credentials from localDb * Filters out unavailable accounts and returns the selected account based on strategy @@ -1024,10 +1179,12 @@ export async function getProviderCredentials( requestedModel: string | null = null, options: CredentialSelectionOptions = {} ) { - const selectionLock = createSelectionLock(getSelectionMutexKey(provider, options)); + const selectionLock = options._leaseRetryWithLockHeld + ? null + : createSelectionLock(getSelectionMutexKey(provider, options)); try { - await selectionLock.wait; + await selectionLock?.wait; // No-auth providers (e.g. opencode) need no DB connection — return synthetic credentials // so the executor receives a valid credentials object without auth headers being added. @@ -1048,6 +1205,15 @@ export async function getProviderCredentials( excludeConnectionId, options.excludeConnectionIds ); + const optionalKey = await loadOptionalNoAuthApiKeyCredentials(resolvedId, excludedForNoAuth); + if ( + optionalKey && + (!allowedConnections || + allowedConnections.length === 0 || + allowedConnections.includes(optionalKey.connectionId)) + ) { + return optionalKey; + } // #9057: when allowedConnections is set, the synthetic "noauth" connection // is never in the explicit allowlist, so we must NOT return it — fall through // to the normal connection-selection path so the connection allowlist is @@ -1099,23 +1265,33 @@ export async function getProviderCredentials( if (allowedConnections && allowedConnections.length > 0) { connections = connections.filter((conn) => allowedConnections.includes(conn.id)); } + const forcedConnectionEligible = connections.some((conn) => conn.id === forcedConnectionId); + if (options.lease && forcedConnectionId && !forcedConnectionEligible) return null; + if (options.lease?.mode === "request" && forcedConnectionId) { + const activeLease = getActiveExclusiveConnectionLease(options.lease.context.leaseOwnerId); + if (activeLease && activeLease.connectionId !== forcedConnectionId) { + return { leaseConnectionMismatch: true }; + } + } // #5903: an active session-affinity pin outranks a per-request reset-aware // forcedConnectionId (see sessionAffinityPin leaf for the full rationale). - forcedConnectionId = - applySessionAffinityPin({ - forcedConnectionId, - options, - sessionAffinityTtlMs, - connections, - provider, - requestedModel, - excludedConnectionIds, - isTerminalConnectionStatus, - isCodexScopeUnavailable, - isQuotaPolicyBlocked: (c) => - evaluateQuotaLimitPolicy(provider, c as ProviderConnectionView, requestedModel).blocked, - }) ?? forcedConnectionId; + if (!options.lease) { + forcedConnectionId = + applySessionAffinityPin({ + forcedConnectionId, + options, + sessionAffinityTtlMs, + connections, + provider, + requestedModel, + excludedConnectionIds, + isTerminalConnectionStatus, + isCodexScopeUnavailable, + isQuotaPolicyBlocked: (c) => + evaluateQuotaLimitPolicy(provider, c as ProviderConnectionView, requestedModel).blocked, + }) ?? forcedConnectionId; + } forcedConnectionId = resolveForcedConnectionForCredentialPool({ forcedConnectionId, @@ -1180,6 +1356,7 @@ export async function getProviderCredentials( "AUTH", `${provider} | all ${allConnections.length} accounts rate limited (${formatRetryAfter(earliest)})` ); + invalidateManagedLease(options, "HEALTH_OR_COOLDOWN"); return { allRateLimited: true, retryAfter: earliest, @@ -1202,6 +1379,7 @@ export async function getProviderCredentials( // the dashboard sees a misleading "bad_request" code. const terminalConnections = allConnections.filter(isTerminalConnectionStatus); if (terminalConnections.length === allConnections.length) { + invalidateManagedLease(options, "AUTHORIZATION_CHANGED"); const syntheticFallback = await maybeSyntheticNoAuthFallback( resolvedId, excludedConnectionIds, @@ -1229,6 +1407,25 @@ export async function getProviderCredentials( allowedConnections ); if (syntheticFallback) return syntheticFallback; + const jinaEnvCredentials = buildJinaEnvCredentials(resolvedId, { + forcedConnectionId, + allowedConnections, + excludedConnectionIds, + }); + if (jinaEnvCredentials) { + log.info("AUTH", `${provider} | using ${jinaEnvCredentials.connectionId} env fallback`); + return jinaEnvCredentials; + } + const geminiEnvCredentials = buildGeminiEnvCredentials(resolvedId, { + forcedConnectionId, + allowedConnections, + excludedConnectionIds, + }); + if (geminiEnvCredentials) { + log.info("AUTH", `${provider} | using ${geminiEnvCredentials.connectionId} env fallback`); + return geminiEnvCredentials; + } + invalidateManagedLease(options, "CONNECTION_INELIGIBLE"); log.warn("AUTH", `No credentials for ${provider}`); return null; } @@ -1428,6 +1625,10 @@ export async function getProviderCredentials( ? `${provider} | all ${connections.length} active accounts cooling down for model ${requestedModel} (${formatRetryAfter(earliest)}) | lastErrorCode=${earliestConn?.errorCode}, lastError=${earliestConn?.lastError?.slice(0, 50)}` : `${provider} | all ${connections.length} active accounts rate limited (${formatRetryAfter(earliest)}) | lastErrorCode=${earliestConn?.errorCode}, lastError=${earliestConn?.lastError?.slice(0, 50)}` ); + invalidateManagedLease( + options, + allBlockedByModelCooldown ? "MODEL_INELIGIBLE" : "HEALTH_OR_COOLDOWN" + ); return { allRateLimited: true, retryAfter: earliest, @@ -1445,6 +1646,7 @@ export async function getProviderCredentials( allowedConnections ); if (syntheticFallback) return syntheticFallback; + invalidateManagedLease(options, "CONNECTION_INELIGIBLE"); log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`); return null; } @@ -1491,6 +1693,7 @@ export async function getProviderCredentials( ? new Date(earliestResetMs).toISOString() : new Date(Date.now() + 5 * 60 * 1000).toISOString(); + invalidateManagedLease(options, "QUOTA_UNAVAILABLE"); return { allRateLimited: true, retryAfter, @@ -1534,6 +1737,7 @@ export async function getProviderCredentials( ? new Date(earliestResetMs).toISOString() : new Date(Date.now() + 5 * 60 * 1000).toISOString(); + invalidateManagedLease(options, "QUOTA_UNAVAILABLE"); return { allRateLimited: true, retryAfter, @@ -1543,7 +1747,30 @@ export async function getProviderCredentials( }; } - const orderedConnections = [...withQuota].sort((a, b) => { + const policyValidLeaseCandidates = options._leaseCandidateIds + ? withQuota.filter((candidate) => options._leaseCandidateIds!.includes(candidate.id)) + : withQuota; + if (policyValidLeaseCandidates.length === 0) return null; + const leasePolicy = await applyExclusiveConnectionLeasePolicy( + policyValidLeaseCandidates, + options + ); + if (leasePolicy.error) return { [leasePolicy.error]: true }; + if (leasePolicy.connections.length === 0) { + if (options.lease?.mode === "request" && leasePolicy.activeLease) { + invalidateManagedLease(options, "CONNECTION_INELIGIBLE"); + } + return options.lease + ? { + waitingForCapacity: true, + retryAfter: leasePolicy.retryAfter, + eligibleCount: policyValidLeaseCandidates.length, + freeCount: 0, + } + : null; + } + + const orderedConnections = [...leasePolicy.connections].sort((a, b) => { if (a.authType !== "oauth" || b.authType !== "oauth") return 0; const priorityDelta = (a.priority || 999) - (b.priority || 999); if (priorityDelta !== 0) return priorityDelta; @@ -1560,16 +1787,35 @@ export async function getProviderCredentials( const providerOverride = providerStrategyOverrides[resolvedId] || {}; const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first"; - let connection; - const affinityConnection = await selectSessionAffinityConnection( - provider, - options.sessionKey, - orderedConnections, - sessionAffinityTtlMs - ); + let commitSelectionSideEffects: (() => Promise | void) | undefined; + let connection = leasePolicy.activeLease + ? orderedConnections.find( + (candidate) => candidate.id === leasePolicy.activeLease?.connectionId + ) + : undefined; + const affinityPlan = + options.lease && !connection + ? planSessionAffinityConnection( + provider, + options.sessionKey, + orderedConnections, + sessionAffinityTtlMs + ) + : null; + const affinityConnection = connection + ? connection + : options.lease + ? affinityPlan?.connection + : await selectSessionAffinityConnection( + provider, + options.sessionKey, + orderedConnections, + sessionAffinityTtlMs + ); if (affinityConnection) { connection = affinityConnection; - syncSessionAffinityRuntimeFields(connectionsRaw, connection); + if (options.lease) commitSelectionSideEffects = affinityPlan?.commit; + else syncSessionAffinityRuntimeFields(connectionsRaw, connection); } else if (options.sessionKey) { log.info( "AUTH", @@ -1611,15 +1857,9 @@ export async function getProviderCredentials( ); // Update lastUsedAt and increment count (await to ensure persistence) const nextCount = (connection.consecutiveUseCount || 0) + 1; - await touchConnectionLastUsed(connection.id, nextCount); - // Sync raw cache row so subsequent calls within TTL see fresh stats - for (const r of connectionsRaw as Record[]) { - if (r.id === connection.id) { - r.lastUsedAt = new Date().toISOString(); - r.consecutiveUseCount = nextCount; - break; - } - } + const commit = planLastUsedCommit(connection, connectionsRaw, nextCount); + if (options.lease) commitSelectionSideEffects = commit; + else await commit(); } else { // Pick the least recently used (excluding current if possible) // Also penalize accounts with high backoffLevel (previously rate-limited) @@ -1642,15 +1882,9 @@ export async function getProviderCredentials( ); // Update lastUsedAt and reset count to 1 (await to ensure persistence) - await touchConnectionLastUsed(connection.id, 1); - // Sync raw cache row so subsequent calls within TTL see fresh LRU stats - for (const r of connectionsRaw as Record[]) { - if (r.id === connection.id) { - r.lastUsedAt = new Date().toISOString(); - r.consecutiveUseCount = 1; - break; - } - } + const commit = planLastUsedCommit(connection, connectionsRaw, 1); + if (options.lease) commitSelectionSideEffects = commit; + else await commit(); } } else { // Fallback scenario: excluded an account due to failure @@ -1673,18 +1907,12 @@ export async function getProviderCredentials( ); // Update lastUsedAt and reset count to 1 (await to ensure persistence) - await touchConnectionLastUsed(connection.id, 1); - // Sync raw cache row so subsequent calls within TTL see fresh stats - for (const r of connectionsRaw as Record[]) { - if (r.id === connection.id) { - r.lastUsedAt = new Date().toISOString(); - r.consecutiveUseCount = 1; - break; - } - } + const commit = planLastUsedCommit(connection, connectionsRaw, 1); + if (options.lease) commitSelectionSideEffects = commit; + else await commit(); } } else if (strategy === "p2c") { - const candidatePool = withQuota.length > 0 ? withQuota : orderedConnections; + const candidatePool = orderedConnections; // Power of Two Choices: sample from the quota-eligible pool and compare // health instead of defaulting to random-first selection. if (candidatePool.length <= 2) { @@ -1726,8 +1954,15 @@ export async function getProviderCredentials( } else if (strategy === "strict-random") { // Strict Random: shuffle deck — uses each account once before reshuffling const ids = orderedConnections.map((c) => c.id); - const selectedId = getNextFromDeckSync(`conn:${provider}`, ids); - connection = orderedConnections.find((c) => c.id === selectedId) || orderedConnections[0]; + if (options.lease) { + const plan = planNextFromDeckSync(`conn:${provider}`, ids); + connection = + orderedConnections.find((c) => c.id === plan.selectedId) || orderedConnections[0]; + commitSelectionSideEffects = plan.commit; + } else { + const selectedId = getNextFromDeckSync(`conn:${provider}`, ids); + connection = orderedConnections.find((c) => c.id === selectedId) || orderedConnections[0]; + } } else { // Default: fill-first (already sorted by priority in getProviderConnections) connection = orderedConnections[0]; @@ -1753,6 +1988,43 @@ export async function getProviderCredentials( if (moreAvailablePeer) connection = moreAvailablePeer; } + let exclusiveLease: ExclusiveConnectionLease | undefined; + if (options.lease) { + const candidateIds = orderedConnections.map((candidate) => candidate.id); + const selectNextLeaseCandidate = (excludedConnectionId: string) => + getProviderCredentials(provider, null, allowedConnections, requestedModel, { + ...options, + excludeConnectionIds: [...excludedConnectionIds, excludedConnectionId], + deferLeaseClaim: true, + _leaseCandidateIds: candidateIds, + }); + if (options.deferLeaseClaim) { + return materializeConnection(connection, options, { + commitSelectionSideEffects, + selectNextLeaseCandidate, + }); + } + let claim = mutateExclusiveConnectionLease( + connection, + leasePolicy.activeLease, + options.lease + ); + if (claim.kind === "LOST") { + return getProviderCredentials(provider, null, allowedConnections, requestedModel, { + ...options, + excludeConnectionIds: [...excludedConnectionIds, connection.id], + _leaseCandidateIds: candidateIds, + _leaseRetryWithLockHeld: true, + }); + } + if (claim.kind === "STALE") return { leaseFenceStale: true }; + exclusiveLease = claim.lease; + await commitSelectionSideEffects?.(); + if (options.materializeCredentials === false) { + return { exclusiveLease, connectionId: connection.id, provider: connection.provider }; + } + } + if (provider === "antigravity" && connection) { log.info( "AUTH", @@ -1760,57 +2032,9 @@ export async function getProviderCredentials( ); } - const apiKeyHealth = connection.providerSpecificData?.apiKeyHealth as - Record | undefined; - if (apiKeyHealth) { - syncHealthFromDB(connection.id, apiKeyHealth); - } - - const releaseOAuthSession = - options.reserveOAuthSession === true && connection.authType === "oauth" && options.sessionKey - ? reserveOAuthSession(connection.id, options.sessionKey) - : undefined; - - return { - apiKey: connection.apiKey, - accessToken: connection.accessToken, - refreshToken: connection.refreshToken, - expiresAt: connection.tokenExpiresAt || connection.expiresAt || null, - projectId: connection.projectId, - // #474: surface the connection's configured defaultModel so the chat / - // embeddings handlers can resolve a bare model name (e.g. an alias that - // resolved to "auto") to a real provider model ID before the upstream call. - defaultModel: connection.defaultModel || null, - copilotToken: - typeof connection.providerSpecificData.copilotToken === "string" - ? connection.providerSpecificData.copilotToken - : null, - providerSpecificData: connection.providerSpecificData, - // Fields the generic quota fetcher (open-sse/services/genericQuotaFetcher.ts) - // needs to delegate to getUsageForProvider for any provider — kept aliased - // (`id` + `connectionId`) for back-compat with callers that already use the - // connectionId name. - id: connection.id, - provider: connection.provider, - authType: connection.authType, - email: connection.email, - connectionId: connection.id, - // Include current status for optimization check - testStatus: connection.testStatus, - lastError: connection.lastError, - lastErrorType: connection.lastErrorType, - lastErrorSource: connection.lastErrorSource, - errorCode: connection.errorCode, - rateLimitedUntil: connection.rateLimitedUntil, - maxConcurrent: connection.maxConcurrent, - // Surface per-window quota overrides so the preflight latency gate in - // getProviderCredentialsWithQuotaPreflight can see them. Without this, - // user-set cutoffs would silently never enforce. - quotaWindowThresholds: connection.quotaWindowThresholds ?? null, - ...(releaseOAuthSession ? { releaseOAuthSession } : {}), - }; + return materializeConnection(connection, options, { exclusiveLease }); } finally { - selectionLock.release(); + selectionLock?.release(); } } export async function getProviderCredentialsWithQuotaPreflight( @@ -1861,18 +2085,17 @@ export async function getProviderCredentialsWithQuotaPreflight( // tighter floor is honored. const FACTORY_NO_OP_REMAINING_PERCENT = 2; const globalDefaultIsRestrictive = defaultThresholdPercent > FACTORY_NO_OP_REMAINING_PERCENT; + let pendingCredentialSelection: Awaited> | undefined; while (true) { - const credentials = await getProviderCredentials( - provider, - null, - allowedConnections, - requestedModel, - { + const credentials = + pendingCredentialSelection ?? + (await getProviderCredentials(provider, null, allowedConnections, requestedModel, { ...options, excludeConnectionIds: Array.from(excludedConnectionIds), - } - ); + ...(options.lease ? { deferLeaseClaim: true } : {}), + })); + pendingCredentialSelection = undefined; if (!credentials) { if (blockedByPreflight.length > 0) { @@ -1895,14 +2118,42 @@ export async function getProviderCredentialsWithQuotaPreflight( return credentials; } - const selectedCredentials = credentials as typeof credentials & { + const selectedCredentials = credentials as Omit< + typeof credentials, + "selectNextLeaseCandidate" + > & { connectionId?: string; + commitSelectionSideEffects?: () => Promise | void; + selectNextLeaseCandidate?: (excludedConnectionId: string) => Promise; releaseOAuthSession?: () => void; }; const connectionId = selectedCredentials.connectionId; if (!connectionId) { return credentials; } + const commitLease = async () => { + if (!options.lease) return credentials; + const activeLease = getActiveExclusiveConnectionLease(options.lease.context.leaseOwnerId); + const claim = mutateExclusiveConnectionLease( + selectedCredentials as unknown as ProviderConnectionView, + activeLease, + options.lease + ); + if (claim.kind === "LOST") { + selectedCredentials.releaseOAuthSession?.(); + excludedConnectionIds.add(connectionId); + pendingCredentialSelection = + await selectedCredentials.selectNextLeaseCandidate?.(connectionId); + return null; + } + if (claim.kind === "STALE") return { leaseFenceStale: true }; + await selectedCredentials.commitSelectionSideEffects?.(); + if (options.materializeCredentials === false) { + selectedCredentials.releaseOAuthSession?.(); + return { exclusiveLease: claim.lease, connectionId, provider }; + } + return { ...credentials, exclusiveLease: claim.lease }; + }; // Cascading resolver: per-connection override → per-(provider, window) // default → global default. Used per-window when the fetcher exposes @@ -1932,17 +2183,23 @@ export async function getProviderCredentialsWithQuotaPreflight( const legacyForceDisable = (credentials as { providerSpecificData?: Record }).providerSpecificData ?.quotaPreflightEnabled === false; - if (legacyForceDisable) return credentials; + if (legacyForceDisable) { + const committed = await commitLease(); + if (committed === null) continue; + return committed; + } const hasConnectionOverrides = Object.keys(perConnectionWindowOverrides).length > 0; - const legacyForceEnable = isQuotaPreflightEnabled(credentials); + const legacyForceEnable = isQuotaPreflightEnabled(credentials as Record); if ( !hasConnectionOverrides && !providerHasDefaults && !legacyForceEnable && !globalDefaultIsRestrictive ) { - return credentials; + const committed = await commitLease(); + if (committed === null) continue; + return committed; } // Returns the minimum-remaining cutoff for a window — matches the @@ -1971,16 +2228,23 @@ export async function getProviderCredentialsWithQuotaPreflight( requestedModel && modelAwarePreflight ? { ...credentials, requestedModel } : credentials; let preflight; try { - preflight = await preflightQuota(provider, connectionId, preflightCredentials, { - resolveMinRemainingPercent, - resolveWarnRemainingPercent: () => warnThresholdPercent, - }); + preflight = await preflightQuota( + provider, + connectionId, + preflightCredentials as Record, + { + resolveMinRemainingPercent, + resolveWarnRemainingPercent: () => warnThresholdPercent, + } + ); } catch (error) { selectedCredentials.releaseOAuthSession?.(); throw error; } if (preflight.proceed) { - return credentials; + const committed = await commitLease(); + if (committed === null) continue; + return committed; } selectedCredentials.releaseOAuthSession?.(); @@ -1997,6 +2261,7 @@ export async function getProviderCredentialsWithQuotaPreflight( resetAt: unavailableUntil, }); excludedConnectionIds.add(connectionId); + pendingCredentialSelection = await selectedCredentials.selectNextLeaseCandidate?.(connectionId); log.info( "AUTH", @@ -2134,6 +2399,26 @@ export async function markAccountUnavailable( } } + // #10460: model-unsupported 400 — the PROVIDER does not serve this model, not + // this account. Cooling down the account and rotating to the next one wastes an + // upstream call because all accounts share the same model catalog. Return + // shouldFallback: false so the error propagates to the combo layer, which already + // has isModelScoped400() (combo.ts:1827) to advance to the next combo target. + // Uses isProviderModelUnsupported400() — the SAME disambiguation + // (AUTH_CREDENTIAL_ERROR_PATTERNS exclusion) checkFallbackError's 400 branch + // applies, narrowed further to exclude the broader/ambiguous + // MODEL_ACCESS_DENIED_PATTERNS access-/permission-phrased matches (e.g. "does not + // have permission to access this model"), which can be an ACCOUNT-scoped + // entitlement gap (PRO vs free tier) rather than a provider-wide unsupported + // model — those must keep rotating to other accounts normally. + if (isProviderModelUnsupported400(status, errorText)) { + log.info( + "AUTH", + `${connectionId.slice(0, 8)} provider_model_unsupported 400 (${provider}/${model ?? "n/a"}) — skipping account cooldown, letting combo advance` + ); + return { shouldFallback: false, cooldownMs: 0, reason: "provider_model_unsupported" }; + } + const effectiveProviderProfile = providerProfile || (provider ? await getRuntimeProviderProfile(provider) : null); // #4530 follow-up: the combo.ts lockout sites forward the admin-configured @@ -2541,27 +2826,14 @@ export async function markAccountUnavailable( }); } - // T-AUTODISABLE: If auto-disable setting is enabled and error is permanent/terminal, - // mark account as inactive so it is never retried again. - // Uses getCachedSettings() to avoid DB overhead on hot error path. - // NOTE: For permanent bans we disable immediately — no threshold needed, - // because a permanent ban (403 "Verify your account" / ToS violation) will - // NEVER recover, so retrying is pointless regardless of attempt count. - if ((result as { permanent?: boolean }).permanent) { - try { - const settings = await getCachedSettings(); - const autoDisableEnabled = settings.autoDisableBannedAccounts ?? false; - if (autoDisableEnabled) { - await updateProviderConnection(connectionId, { isActive: false }); - log.info( - "AUTH", - `Auto-disabled ${connectionId.slice(0, 8)} — permanent ban detected (autoDisableBannedAccounts=true)` - ); - } - } catch (e) { - log.info("AUTH", `Auto-disable check failed (non-fatal): ${e}`); - } - } + // T-AUTODISABLE: permanent bans disable immediately when the setting allows it. + await maybeAutoDisableBannedAccount({ + connectionId, + provider, + authType: conn?.authType, + connectionProvider: conn?.provider, + permanent: Boolean((result as { permanent?: boolean }).permanent), + }); if (provider && status && errorMsg) { console.error(`❌ ${provider} [${status}]: ${errorMsg}`); diff --git a/src/sse/services/autoDisableBannedAccount.ts b/src/sse/services/autoDisableBannedAccount.ts new file mode 100644 index 0000000000..2d1d8d33d1 --- /dev/null +++ b/src/sse/services/autoDisableBannedAccount.ts @@ -0,0 +1,51 @@ +/** + * Permanent-ban auto-disable for markAccountUnavailable. + * + * Extracted from auth.ts so the frozen god-file only keeps the call site. + * Scope (all vs subscription) lives in shared/utils/autoDisableBanned.ts. + */ + +import { getCachedSettings } from "@/lib/db/readCache"; +import { updateProviderConnection } from "@/lib/db/providers"; +import { resolveProviderId, WEB_COOKIE_PROVIDERS } from "@/shared/constants/providers"; +import { shouldAutoDisableBannedConnection } from "@/shared/utils/autoDisableBanned"; +import * as log from "../utils/logger"; + +/** Deactivate a connection after a permanent ban when settings and scope allow it. */ +export async function maybeAutoDisableBannedAccount(input: { + connectionId: string; + provider?: string | null; + authType?: string | null; + connectionProvider?: string | null; + permanent?: boolean; +}): Promise { + if (!input.permanent) return; + try { + const settings = await getCachedSettings(); + const scope = settings.autoDisableBannedScope; + if ( + !shouldAutoDisableBannedConnection({ + enabled: Boolean(settings.autoDisableBannedAccounts), + scope, + authType: input.authType, + providerId: resolveProviderId(input.provider || input.connectionProvider || ""), + webCookieProviderIds: WEB_COOKIE_PROVIDERS, + }) + ) { + if (settings.autoDisableBannedAccounts) { + log.info( + "AUTH", + `Skipped auto-disable for ${input.connectionId.slice(0, 8)} — permanent ban recorded, scope=${scope || "all"} authType=${input.authType || "unknown"}` + ); + } + return; + } + await updateProviderConnection(input.connectionId, { isActive: false }); + log.info( + "AUTH", + `Auto-disabled ${input.connectionId.slice(0, 8)} — permanent ban detected (autoDisableBannedAccounts=true, scope=${settings.autoDisableBannedScope || "all"})` + ); + } catch (error) { + log.info("AUTH", `Auto-disable check failed (non-fatal): ${error}`); + } +} diff --git a/src/sse/services/exclusiveConnectionLeasePolicy.ts b/src/sse/services/exclusiveConnectionLeasePolicy.ts new file mode 100644 index 0000000000..c56e5256da --- /dev/null +++ b/src/sse/services/exclusiveConnectionLeasePolicy.ts @@ -0,0 +1,125 @@ +import { getExclusiveLeaseConnectionIds } from "@/lib/db/apiKeys"; +import { + acquireExclusiveConnectionLease, + getActiveExclusiveConnectionLease, + getExclusiveLeaseOccupancy, + hashLeaseOwnerId, + invalidateExclusiveConnectionLease, + transitionExclusiveConnectionLease, + type ExclusiveConnectionLease, + type ExclusiveLeaseEndReason, +} from "@/lib/db/exclusiveConnectionLeases"; +import type { ProviderConnectionView } from "@/lib/db/providers/lazyConnectionView"; + +import type { ManagedLeaseRequestContext } from "./leaseContext"; + +export interface CredentialLeaseSelectionContext { + apiKeyId: string; + context: ManagedLeaseRequestContext; + mode: "acquire" | "request"; +} + +export interface LeaseSelectionOptions { + forcedConnectionId?: string | null; + lease?: CredentialLeaseSelectionContext; +} + +export type LeaseCandidatePolicy = { + connections: ProviderConnectionView[]; + activeLease: ExclusiveConnectionLease | null; + retryAfter: string | null; + error?: "leaseConnectionMismatch" | "leaseFenceStale" | "leaseRequired"; +}; + +export async function applyExclusiveConnectionLeasePolicy( + connections: ProviderConnectionView[], + options: LeaseSelectionOptions +): Promise { + const occupancy = getExclusiveLeaseOccupancy(connections.map((connection) => connection.id)); + if (!options.lease) { + const managed = await getExclusiveLeaseConnectionIds(); + return { + connections: connections.filter( + (connection) => !managed.has(connection.id) && !occupancy.has(connection.id) + ), + activeLease: null, + retryAfter: null, + }; + } + + const { lease } = options; + const activeLease = getActiveExclusiveConnectionLease(lease.context.leaseOwnerId); + if (lease.mode === "request" && !activeLease) { + return { connections: [], activeLease: null, retryAfter: null, error: "leaseRequired" }; + } + if (lease.mode === "request" && activeLease?.generation !== lease.context.generation) { + return { connections: [], activeLease, retryAfter: null, error: "leaseFenceStale" }; + } + if (activeLease && activeLease.apiKeyId !== lease.apiKeyId) { + return { connections: [], activeLease, retryAfter: null, error: "leaseFenceStale" }; + } + const activeBinding = activeLease + ? connections.find((connection) => connection.id === activeLease.connectionId) + : undefined; + if (!activeBinding && lease.mode === "request" && activeLease && options.forcedConnectionId) { + return { connections: [], activeLease, retryAfter: null, error: "leaseConnectionMismatch" }; + } + const ownerHash = hashLeaseOwnerId(lease.context.leaseOwnerId); + const free = connections.filter((connection) => + occupancy.get(connection.id)?.leaseOwnerHash !== ownerHash && occupancy.has(connection.id) + ? false + : connection.id !== activeBinding?.id + ); + return { + connections: activeBinding ? [activeBinding, ...free] : free, + activeLease, + retryAfter: + [...occupancy.values()] + .filter((row) => row.leaseOwnerHash !== ownerHash) + .map((row) => row.expiresAt) + .sort()[0] ?? null, + }; +} + +export function mutateExclusiveConnectionLease( + connection: ProviderConnectionView, + activeLease: ExclusiveConnectionLease | null, + lease: CredentialLeaseSelectionContext, + reason: ExclusiveLeaseEndReason = "CONNECTION_INELIGIBLE" +) { + const result = activeLease + ? transitionExclusiveConnectionLease({ + leaseOwnerId: lease.context.leaseOwnerId, + generation: lease.mode === "acquire" ? activeLease.generation : lease.context.generation, + apiKeyId: lease.apiKeyId, + provider: connection.provider, + connectionId: connection.id, + reason, + }) + : acquireExclusiveConnectionLease({ + leaseOwnerId: lease.context.leaseOwnerId, + apiKeyId: lease.apiKeyId, + provider: connection.provider, + connectionId: connection.id, + }); + if (result.kind === "CONNECTION_BUSY") { + return { kind: "LOST" as const, retryAfter: result.retryAfter }; + } + if (result.kind === "STALE" || result.kind === "OWNER_ALREADY_ACTIVE") { + return { kind: "STALE" as const }; + } + return { kind: "CLAIMED" as const, lease: result.lease }; +} + +export function invalidateManagedConnectionLease( + lease: CredentialLeaseSelectionContext | undefined, + reason: ExclusiveLeaseEndReason +): void { + if (lease?.mode !== "request") return; + invalidateExclusiveConnectionLease({ + leaseOwnerId: lease.context.leaseOwnerId, + generation: lease.context.generation, + apiKeyId: lease.apiKeyId, + reason, + }); +} diff --git a/src/sse/services/imageCredentialRetry.ts b/src/sse/services/imageCredentialRetry.ts index c43d033a02..5758aed108 100644 --- a/src/sse/services/imageCredentialRetry.ts +++ b/src/sse/services/imageCredentialRetry.ts @@ -9,6 +9,14 @@ interface ImageGenerationResult { status?: number; error?: unknown; data?: unknown; + // #10494: opt-in signal a provider handler can set (via + // saveImageErrorResult's `retryable` option) when a non-401 failure is + // still account/session-specific — e.g. an expired or blocked Gemini Web + // session, which the underlying browser-automation executor surfaces as + // 400/500 rather than 401. Only honored together with a connectionId, same + // as the existing 401 path, so providers that never set it keep the + // original 401-only fallback behavior unchanged. + retryable?: boolean; } interface ImageCredentialRetryOptions { @@ -16,6 +24,14 @@ interface ImageCredentialRetryOptions { requestedModel: string | null; credentials: any; execute: (credentials: any) => Promise; + // Injectable so unit tests can drive multi-account fallback deterministically + // without a live DB-backed credential store; production always uses the real + // getProviderCredentialsWithQuotaPreflight-backed selectNextCredentials below. + selectNextCredentials?: ( + provider: string, + requestedModel: string | null, + excludedConnectionIds: Set + ) => Promise; } interface ImageCredentialRetryResult { @@ -34,7 +50,7 @@ function isCredentialSentinel(credentials: any): boolean { return Boolean(credentials?.allRateLimited || credentials?.allExpired); } -async function selectNextCredentials( +async function defaultSelectNextCredentials( provider: string, requestedModel: string | null, excludedConnectionIds: Set @@ -56,6 +72,7 @@ export async function executeImageWithCredentialFallback({ requestedModel, credentials, execute, + selectNextCredentials = defaultSelectNextCredentials, }: ImageCredentialRetryOptions): Promise { // Local/no-auth image providers intentionally have no credential row. They // still need one direct attempt, but there is no account identity to refresh @@ -93,7 +110,8 @@ export async function executeImageWithCredentialFallback({ lastCredentials = currentCredentials; lastResult = await execute(currentCredentials); - if (lastResult.success || Number(lastResult.status) !== 401 || !connectionId) { + const isAuthFailure = Number(lastResult.status) === 401 || lastResult.retryable === true; + if (lastResult.success || !isAuthFailure || !connectionId) { return { credentials: lastCredentials, result: lastResult }; } diff --git a/src/sse/services/leaseContext.ts b/src/sse/services/leaseContext.ts new file mode 100644 index 0000000000..9b5370c133 --- /dev/null +++ b/src/sse/services/leaseContext.ts @@ -0,0 +1,136 @@ +import { LEASE_OWNER_PATTERN } from "@/lib/db/exclusiveConnectionLeases"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts"; + +export const LEASE_EXCLUSIVE_SCOPE = "lease:exclusive", + LEASE_OWNER_HEADER = "X-OmniRoute-Lease-Owner", + LEASE_GENERATION_HEADER = "X-OmniRoute-Lease-Generation"; + +export type ManagedLeaseRequestContext = { leaseOwnerId: string; generation: number }; +export type ManagedLeaseDispatchContext = { + apiKeyId: string; + context: ManagedLeaseRequestContext; +}; +export const credentialLease = (lease: ManagedLeaseDispatchContext) => ({ + ...lease, + mode: "request" as const, +}); + +export class LeaseContextError extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string + ) { + super(message); + } +} + +type LeaseKeyMetadata = { + scopes?: readonly string[] | null; + allowedConnections?: readonly string[] | null; +}; + +export function isExclusiveLeaseManagedKey(metadata: LeaseKeyMetadata | null | undefined): boolean { + return Array.isArray(metadata?.scopes) && metadata.scopes.includes(LEASE_EXCLUSIVE_SCOPE); +} + +export function validateExclusiveLeaseKeyConfiguration( + metadata: LeaseKeyMetadata | null | undefined +): void { + if (!isExclusiveLeaseManagedKey(metadata)) return; + if (!metadata?.allowedConnections?.length) + throw new LeaseContextError( + 403, + "LEASE_KEY_CONFIGURATION_INVALID", + "Exclusive lease keys require explicit allowed connections" + ); +} + +export function parseLeaseOwnerHeader(headers: Headers): string { + const leaseOwnerId = headers.get(LEASE_OWNER_HEADER)?.trim() ?? ""; + if (!leaseOwnerId) + throw new LeaseContextError(400, "LEASE_CONTEXT_REQUIRED", "Explicit lease owner required"); + if (!LEASE_OWNER_PATTERN.test(leaseOwnerId)) + throw new LeaseContextError(400, "LEASE_CONTEXT_INVALID", "Malformed lease owner"); + return leaseOwnerId; +} + +export function parseManagedLeaseRequestContext(headers: Headers): ManagedLeaseRequestContext { + const leaseOwnerId = parseLeaseOwnerHeader(headers); + const rawGeneration = headers.get(LEASE_GENERATION_HEADER)?.trim() ?? ""; + if (!/^[1-9]\d*$/.test(rawGeneration)) + throw new LeaseContextError(400, "LEASE_CONTEXT_INVALID", "Positive lease generation required"); + const generation = Number(rawGeneration); + if (!Number.isSafeInteger(generation)) + throw new LeaseContextError( + 400, + "LEASE_CONTEXT_INVALID", + "The lease generation header is invalid" + ); + return { leaseOwnerId, generation }; +} + +export function buildManagedLeaseErrorResponse(error: LeaseContextError): Response { + return new Response( + JSON.stringify( + buildErrorBody(error.status, error.message, undefined, { + type: "lease_error", + code: error.code, + }) + ), + { status: error.status, headers: { "Content-Type": "application/json" } } + ); +} + +type ManagedLeaseSelectionFailure = { + eligibleCount?: number; + freeCount?: number; + leaseConnectionMismatch?: boolean; + leaseFenceStale?: boolean; + leaseRequired?: boolean; + retryAfter?: string | null; + waitingForCapacity?: boolean; + allRateLimited?: boolean; +}; + +export function buildManagedLeaseSelectionErrorResponse( + selection: ManagedLeaseSelectionFailure +): Response | null { + const code = selection.leaseRequired + ? "LEASE_REQUIRED" + : selection.leaseFenceStale + ? "LEASE_FENCE_STALE" + : selection.leaseConnectionMismatch + ? "LEASE_CONNECTION_MISMATCH" + : null; + if (code) + return buildManagedLeaseErrorResponse( + new LeaseContextError(409, code, code.replaceAll("_", " ")) + ); + if (!selection.waitingForCapacity) return null; + const expiryMs = Date.parse(selection.retryAfter ?? ""); + const retryAfterSeconds = Number.isFinite(expiryMs) + ? Math.min(3600, Math.max(1, Math.ceil((expiryMs - Date.now()) / 1000))) + : 1; + return new Response( + JSON.stringify({ + state: "WAITING_FOR_CAPACITY", + error: { + type: "lease_error", + code: "LEASE_CAPACITY_UNAVAILABLE", + message: "Exclusive managed session capacity is temporarily unavailable", + }, + reason: "NO_FREE_ELIGIBLE_CONNECTION", + retryAfter: retryAfterSeconds, + eligibleCount: Math.max(0, selection.eligibleCount ?? 0), + freeCount: Math.max(0, selection.freeCount ?? 0), + }), + { + status: 429, + headers: { + "Content-Type": "application/json", + "Retry-After": String(retryAfterSeconds), + }, + } + ); +} diff --git a/src/sse/services/noAuthOptionalApiKey.ts b/src/sse/services/noAuthOptionalApiKey.ts new file mode 100644 index 0000000000..823974cfd8 --- /dev/null +++ b/src/sse/services/noAuthOptionalApiKey.ts @@ -0,0 +1,127 @@ +/** + * Optional API keys on no-auth providers (AI Horde). + * + * `getProviderCredentials` short-circuits no-auth providers to a synthetic + * `connectionId: "noauth"` row so they work with nothing configured. That + * skipped stored connections, so a registered Horde key could be saved and + * still never sent. When a no-auth provider also accepts an optional key + * (`anonymousApiKey` and/or FREE_APIKEY), prefer an active connection that + * actually has a key, then fall back to the synthetic anonymous path. + */ +import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; +import { isAccountUnavailable } from "@omniroute/open-sse/services/accountFallback.ts"; +import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView"; +import type { ProviderConnectionView } from "@/lib/db/providers/lazyConnectionView"; +import { getCachedRawProviderConnections } from "@/lib/db/readCache"; +import { supportsApiKeyOnFreeProvider } from "@/shared/constants/providers"; + +export function noAuthProviderAcceptsOptionalApiKey(providerId: string): boolean { + if (supportsApiKeyOnFreeProvider(providerId)) return true; + const entry = REGISTRY[providerId] as { anonymousApiKey?: string } | undefined; + return Boolean(entry?.anonymousApiKey); +} + +function hasUsableApiKey(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +// Terminal statuses stay unavailable until credentials/settings change — an +// operator reset, not a cooldown expiry, clears them (see auth.ts's +// isTerminalConnectionStatus, which this mirrors for the optional-key path). +const TERMINAL_TEST_STATUSES = new Set(["credits_exhausted", "banned", "expired"]); + +/** + * A stored optional key is only usable when it passes the same connection + * health checks the normal credential-selection path enforces: not in an + * active rate-limit/cooldown window (`rateLimitedUntil`), and not parked in + * a terminal or transient-unavailable `testStatus`. Without this, a + * rate-limited or banned stored Horde key could get selected here — bypassing + * cooldown entirely — instead of falling back to the anonymous no-auth path + * or rotating to the next healthy key. + */ +function isConnectionHealthy(connection: ProviderConnectionView): boolean { + if (isAccountUnavailable(connection.rateLimitedUntil)) return false; + const status = (connection.testStatus || "").trim().toLowerCase(); + if (TERMINAL_TEST_STATUSES.has(status)) return false; + if (status === "unavailable") return false; + return true; +} + +export async function loadOptionalNoAuthApiKeyCredentials( + providerId: string, + excludedConnectionIds: Set +): Promise<{ + apiKey: string; + accessToken: null; + refreshToken: null; + expiresAt: null; + projectId: null; + defaultModel: string | null; + copilotToken: null; + providerSpecificData: Record; + id: string; + provider: string; + connectionId: string; + testStatus: string | null; + lastError: null; + lastErrorType: null; + lastErrorSource: null; + errorCode: null; + rateLimitedUntil: null; + maxConcurrent: null; +} | null> { + if (!noAuthProviderAcceptsOptionalApiKey(providerId)) return null; + + let connectionsRaw: unknown; + try { + connectionsRaw = await getCachedRawProviderConnections({ + provider: providerId, + isActive: true, + }); + } catch { + return null; + } + + const connections = (Array.isArray(connectionsRaw) ? connectionsRaw : []) + .map(createLazyConnectionView) + .filter( + (conn) => + conn.id.length > 0 && + !excludedConnectionIds.has(conn.id) && + conn.isActive !== false && + hasUsableApiKey(conn.apiKey) + ) + .sort((a, b) => (a.priority || 999) - (b.priority || 999)); + + // Rotate past unhealthy (cooling-down/terminal) stored keys instead of + // handing one back regardless of health. If every candidate is unhealthy, + // fall through to the caller's anonymous/synthetic no-auth fallback. + const connection = connections.find(isConnectionHealthy); + if (!connection || !hasUsableApiKey(connection.apiKey)) return null; + + const providerSpecificData = + connection.providerSpecificData && typeof connection.providerSpecificData === "object" + ? (connection.providerSpecificData as Record) + : {}; + + return { + apiKey: connection.apiKey.trim(), + accessToken: null, + refreshToken: null, + expiresAt: null, + projectId: null, + defaultModel: connection.defaultModel || null, + copilotToken: null, + providerSpecificData, + id: connection.id, + provider: connection.provider || providerId, + connectionId: connection.id, + testStatus: connection.testStatus ?? "active", + lastError: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + rateLimitedUntil: null, + maxConcurrent: null, + }; +} diff --git a/src/sse/services/sessionAffinityPin.ts b/src/sse/services/sessionAffinityPin.ts index a2055e0fa5..f7c5009df6 100644 --- a/src/sse/services/sessionAffinityPin.ts +++ b/src/sse/services/sessionAffinityPin.ts @@ -141,6 +141,43 @@ export async function selectSessionAffinityConnection( + provider: string, + sessionKey: string | null | undefined, + connections: T[], + ttlMs = 0 +) { + if (!sessionKey || connections.length === 0 || ttlMs <= 0) return null; + const existing = getSessionAccountAffinity(sessionKey, provider, ttlMs); + const existingConnection = + existing && connections.find((candidate) => candidate.id === existing.connectionId); + const connection = existingConnection ?? [...connections].sort(compareLruConnections)[0] ?? null; + if (!connection) return null; + + return { + connection, + commit: async () => { + if (existingConnection) { + touchSessionAccountAffinity(sessionKey, provider, Date.now(), ttlMs); + const nextCount = (connection.consecutiveUseCount || 0) + 1; + await touchConnectionLastUsed(connection.id, nextCount); + connection.lastUsedAt = new Date().toISOString(); + connection.consecutiveUseCount = nextCount; + return; + } + if (existing) deleteSessionAccountAffinity(sessionKey, provider); + upsertSessionAccountAffinity(sessionKey, provider, connection.id, Date.now(), ttlMs); + await touchConnectionLastUsed(connection.id, 1); + connection.lastUsedAt = new Date().toISOString(); + connection.consecutiveUseCount = 1; + }, + }; +} + /** Inputs the combo-timeout eviction needs from the dispatch site. */ export interface ComboTimeoutAffinityEvictionParams { sessionKey?: string | null; diff --git a/stryker.conf.json b/stryker.conf.json index 13f95dd37c..61c50f3db9 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -39,9 +39,7 @@ "incremental": true, "incrementalFile": "reports/mutation/stryker-incremental.json", "testRunner": "tap", - "plugins": [ - "@stryker-mutator/tap-runner" - ], + "plugins": ["@stryker-mutator/tap-runner"], "tap": { "testFiles": [ "tests/unit/7993-noauth-proxy-routing.test.ts", @@ -53,6 +51,7 @@ "tests/unit/8396-cooldown-429-cap.test.ts", "tests/unit/8488-capability-filter-fail-closed.test.ts", "tests/unit/8779-agy-prefix-credential-lookup.test.ts", + "tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts", "tests/unit/account-fallback-anthropic-quota.test.ts", "tests/unit/account-fallback-cf1010-no-retry-8775.test.ts", "tests/unit/account-fallback-lockout-eviction.test.ts", @@ -357,6 +356,7 @@ "tests/unit/usage-service-hardening.test.ts", "tests/unit/validate-response-quality.test.ts", "tests/unit/vertex-passthrough-model-lockout.test.ts", + "tests/unit/video-bridge-drilldown-route.test.ts", "tests/unit/video-bridge-route-security.test.ts", "tests/unit/xai-agent-tools-passthrough.test.ts" ], @@ -467,11 +467,7 @@ ".worktrees", ".stryker-tmp" ], - "reporters": [ - "progress", - "html", - "json" - ], + "reporters": ["progress", "html", "json"], "htmlReporter": { "fileName": "reports/mutation/mutation.html" }, diff --git a/tests/e2e/providers-bailian-coding-plan.spec.ts b/tests/e2e/providers-bailian-coding-plan.spec.ts index 4194d6bf34..01e143b826 100644 --- a/tests/e2e/providers-bailian-coding-plan.spec.ts +++ b/tests/e2e/providers-bailian-coding-plan.spec.ts @@ -3,7 +3,8 @@ import { gotoDashboardRoute } from "./helpers/dashboardAuth"; // #7882 replaced this provider's free-text Base URL field with a region step: // the endpoint is now derived from the choice ("global-sg" -> -// coding-intl.dashscope.aliyuncs.com, "china-beijing" -> coding.dashscope.aliyuncs.com, +// token-plan.ap-southeast-1.maas.aliyuncs.com, "china-beijing" -> +// token-plan.cn-beijing.maas.aliyuncs.com, // see src/shared/constants/alibabaProviderRegions.ts), so the modal persists // providerSpecificData.region instead of a baseUrl. A per-connection base-URL // override still exists, but it moved to Advanced in the edit-connection modal. @@ -120,7 +121,7 @@ test.describe("Bailian Coding Plan Provider", () => { // free-text Base URL field, which #7882 removed for this provider — an invalid // URL is no longer reachable from this modal. Replaced with the other half of // the region contract: the China-mainland choice must persist as typed, since - // that is what selects the coding.dashscope.aliyuncs.com endpoint. + // that is what selects the token-plan.cn-beijing.maas.aliyuncs.com endpoint. test("region step persists the China-mainland (Beijing) choice", async ({ page }) => { const capturedPayloads: { createProvider?: Record } = {}; @@ -222,6 +223,8 @@ test.describe("Bailian Coding Plan Provider", () => { expect(capturedPayloads.createProvider).toBeDefined(); const payload = capturedPayloads.createProvider; expect(payload?.providerSpecificData).toBeDefined(); - expect((payload?.providerSpecificData as Record)?.region).toBe("china-beijing"); + expect((payload?.providerSpecificData as Record)?.region).toBe( + "china-beijing" + ); }); }); diff --git a/tests/integration/combo-matrix/auto.test.ts b/tests/integration/combo-matrix/auto.test.ts index dd19a76599..ebc1362128 100644 --- a/tests/integration/combo-matrix/auto.test.ts +++ b/tests/integration/combo-matrix/auto.test.ts @@ -51,7 +51,6 @@ const NO_AUTH_PROVIDER_IDS = [ "theoldllm", "chipotle", "veoaifree-web", - "mimocode", "auggie", ]; diff --git a/tests/integration/live-ws-heartbeat-keepalive.test.ts b/tests/integration/live-ws-heartbeat-keepalive.test.ts new file mode 100644 index 0000000000..b3ba3f882c --- /dev/null +++ b/tests/integration/live-ws-heartbeat-keepalive.test.ts @@ -0,0 +1,176 @@ +// Integration test for #10452: a server-emitted application-level pong must not +// keep a half-open client alive. Uses the real server harness from +// tests/integration/live-ws-startup.test.ts (serial, --test-concurrency=1 +// integration runner — this test needs a ~50s window to cross the server's +// HEARTBEAT_TIMEOUT_MS, which is intentionally NOT inflated here). +import assert from "node:assert/strict"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import net from "node:net"; +import test from "node:test"; +import WebSocket from "ws"; + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close(() => { + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("Failed to allocate a local port")); + }); + }); + }); +} + +function terminateTree(child: ChildProcessWithoutNullStreams): void { + if (!child.pid) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } +} + +function waitForStartup( + child: ChildProcessWithoutNullStreams, + getOutput: () => string +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`LiveWS startup timed out. Output:\n${getOutput()}`)); + }, 30_000); + + const onData = () => { + const output = getOutput(); + if (output.includes("Dashboard WebSocket server listening")) { + cleanup(); + resolve(); + } + }; + + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject( + new Error(`LiveWS exited before listening: code=${code} signal=${signal}\n${getOutput()}`) + ); + }; + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", onData); + child.stderr.off("data", onData); + child.off("exit", onExit); + }; + + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.once("exit", onExit); + onData(); + }); +} + +test( + "LiveWS removes a silent socket but keeps one answering protocol heartbeats (#10452)", + // A fresh DATA_DIR can spend up to the server-startup allowance running the + // complete migration set before the 50s heartbeat observation window. + { timeout: 95_000 }, + async () => { + const port = await getFreePort(); + const apiKey = "test-live-ws-heartbeat-key"; + const jwtSecret = "test-live-ws-heartbeat-jwt-secret"; + const origin = "http://localhost"; + let output = ""; + + const child = spawn(process.execPath, ["scripts/start-ws-server.mjs"], { + cwd: process.cwd(), + detached: process.platform !== "win32", + env: { + ...process.env, + NODE_ENV: "test", + OMNIROUTE_API_KEY: apiKey, + JWT_SECRET: jwtSecret, + LIVE_WS_HOST: "127.0.0.1", + LIVE_WS_PORT: String(port), + LIVE_WS_ALLOWED_ORIGINS: origin, + }, + }); + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.stderr.on("data", (chunk) => { + output += chunk; + }); + + try { + await waitForStartup(child, () => output); + + const connect = (answerHeartbeat: boolean) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}/live-ws`, { + headers: { Authorization: `Bearer ${apiKey}`, Origin: origin }, + }); + let heartbeat: NodeJS.Timeout | undefined; + const welcome = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timed out waiting for welcome. Output:\n${output}`)); + }, 5_000); + + ws.once("open", () => { + ws.send(JSON.stringify({ type: "subscribe", channels: ["requests"] })); + if (answerHeartbeat) { + heartbeat = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ping" })); + } + }, 10_000); + } + }); + + ws.on("message", (data) => { + if (JSON.parse(data.toString()).type === "welcome") { + clearTimeout(timeout); + resolve(); + } + }); + + ws.once("error", (error) => { + clearTimeout(timeout); + reject(new Error(`LiveWS client failed: ${error.message}. Output:\n${output}`)); + }); + }); + + return { ws, welcome, stop: () => clearInterval(heartbeat) }; + }; + + const silent = connect(false); + const responsive = connect(true); + await Promise.all([silent.welcome, responsive.welcome]); + + // Wait past HEARTBEAT_TIMEOUT_MS (35s) plus one heartbeat interval (15s). + // The server emits application-level pong frames during this period, but + // only the responsive client sends the inbound { type: "ping" } signal. + await new Promise((resolve) => setTimeout(resolve, 50_000)); + + assert.equal( + silent.ws.readyState, + WebSocket.CLOSED, + `Silent socket remained alive after timeout — server pong renewed lastActivity. Output:\n${output}` + ); + assert.notEqual( + responsive.ws.readyState, + WebSocket.CLOSED, + `Protocol-heartbeat client was terminated. Output:\n${output}` + ); + + silent.stop(); + responsive.stop(); + silent.ws.close(); + responsive.ws.close(); + } finally { + terminateTree(child); + } + } +); diff --git a/tests/integration/mimocode-proxy.integration.test.ts b/tests/integration/mimocode-proxy.integration.test.ts deleted file mode 100644 index 85e5934e49..0000000000 --- a/tests/integration/mimocode-proxy.integration.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { describe, it, before } from "node:test"; -import assert from "node:assert"; -import { MimocodeExecutor, generateFingerprint } from "../../open-sse/executors/mimocode.ts"; - -const PROXY_URL = process.env.MIMOCODE_SOCKS5_PROXY; - -function parseProxyUrl(url: string): { type: string; host: string; port: number } | null { - try { - const parsed = new URL(url); - return { type: parsed.protocol.replace(":", ""), host: parsed.hostname, port: parsed.port ? Number(parsed.port) : 1080 }; - } catch { - return null; - } -} - -function requireProxy() { - if (!PROXY_URL) { - return false; - } - const parsed = parseProxyUrl(PROXY_URL); - return parsed !== null; -} - -const proxyConfig = PROXY_URL ? parseProxyUrl(PROXY_URL) : null; - -describe("mimocode per-account proxy — SOCKS5 integration", { timeout: 30_000 }, () => { - before(() => { - if (!PROXY_URL) { - console.log("# MIMOCODE_SOCKS5_PROXY not set, skipping live proxy tests"); - } - }); - - it("bootstrap returns JWT through configured proxy", { skip: !requireProxy() ? "MIMOCODE_SOCKS5_PROXY not set" : false }, async () => { - process.env.ENABLE_SOCKS5_PROXY = "true"; - const { Socks5ProxyAgent } = await import("undici"); - const agent = new Socks5ProxyAgent(PROXY_URL!); - - const fp = generateFingerprint("integration-bootstrap-" + Date.now()); - const resp = await fetch("https://api.xiaomimimo.com/api/free-ai/bootstrap", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ client: fp }), - // @ts-expect-error — undici dispatcher - dispatcher: agent, - signal: AbortSignal.timeout(15_000), - }); - assert.strictEqual(resp.status, 200, `Bootstrap through proxy: expected 200, got ${resp.status}`); - const data = await resp.json(); - assert.ok(data.jwt, "Response should contain JWT"); - assert.ok(typeof data.jwt === "string" && data.jwt.length > 10, "JWT should be a non-trivial string"); - }); - - it("chat request succeeds through configured proxy", { skip: !requireProxy() ? "MIMOCODE_SOCKS5_PROXY not set" : false }, async () => { - process.env.ENABLE_SOCKS5_PROXY = "true"; - const { Socks5ProxyAgent } = await import("undici"); - const agent = new Socks5ProxyAgent(PROXY_URL!); - - const fp = generateFingerprint("integration-chat-" + Date.now()); - const bootstrapResp = await fetch("https://api.xiaomimimo.com/api/free-ai/bootstrap", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ client: fp }), - // @ts-expect-error — undici dispatcher - dispatcher: agent, - signal: AbortSignal.timeout(15_000), - }); - assert.strictEqual(bootstrapResp.status, 200); - const { jwt } = await bootstrapResp.json(); - - const chatResp = await fetch("https://api.xiaomimimo.com/api/free-ai/openai/chat", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${jwt}`, - "X-Mimo-Source": "mimocode-cli-free", - }, - body: JSON.stringify({ - model: "mimo-auto", - messages: [ - { role: "system", content: "You are MiMoCode, an interactive CLI tool that helps users with software engineering tasks." }, - { role: "user", content: "Say exactly: proxy-integration-ok" }, - ], - stream: false, - }), - // @ts-expect-error — undici dispatcher - dispatcher: agent, - signal: AbortSignal.timeout(20_000), - }); - assert.ok(chatResp.status === 200 || chatResp.status === 429, - `Chat through proxy: expected 200/429, got ${chatResp.status}`); - }); - - it("accounts carry proxy config after sync", () => { - const exec = new MimocodeExecutor(); - const fp = "integration-fp-1"; - const cfg = proxyConfig || { type: "socks5", host: "127.0.0.1", port: 1080 }; - (exec as any).accounts = [ - { fingerprint: fp, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null }, - ]; - (exec as any).nextAccountIdx = 0; - - (exec as any).syncAccountsFromCredentials({ - providerSpecificData: { - accountProxies: [{ fingerprint: fp, proxy: cfg }], - }, - }); - - const acct = (exec as any).accounts.find((a: any) => a.fingerprint === fp); - assert.ok(acct, "Account should exist"); - assert.deepStrictEqual(acct.proxy, cfg); - }); - - it("two accounts with different proxies tracked independently", () => { - const exec = new MimocodeExecutor(); - const fp1 = "integration-fp-a"; - const fp2 = "integration-fp-b"; - const proxy1 = { type: "http" as const, host: "proxy-a.example.com", port: 8080 }; - const proxy2 = { type: "socks5" as const, host: "proxy-b.example.com", port: 1080 }; - - (exec as any).accounts = [ - { fingerprint: fp1, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null }, - { fingerprint: fp2, jwt: "", expiresAt: 0, cooldownUntil: 0, consecutiveFails: 0, proxy: null }, - ]; - (exec as any).nextAccountIdx = 0; - - (exec as any).syncAccountsFromCredentials({ - providerSpecificData: { - accountProxies: [ - { fingerprint: fp1, proxy: proxy1 }, - { fingerprint: fp2, proxy: proxy2 }, - ], - }, - }); - - const a1 = (exec as any).accounts.find((a: any) => a.fingerprint === fp1); - const a2 = (exec as any).accounts.find((a: any) => a.fingerprint === fp2); - assert.deepStrictEqual(a1.proxy, proxy1, "Account 1 should have proxy1"); - assert.deepStrictEqual(a2.proxy, proxy2, "Account 2 should have proxy2"); - assert.notDeepStrictEqual(a1.proxy, a2.proxy, "Proxies should differ"); - }); - - it("no accountProxies keeps all proxies null (backward compat)", () => { - const exec = new MimocodeExecutor(); - const accounts = (exec as any).accounts; - assert.ok(accounts.length >= 1); - for (const acct of accounts) { - assert.strictEqual(acct.proxy, null, "Default account proxy should be null"); - } - }); -}); diff --git a/tests/integration/search-providers-catalog.test.ts b/tests/integration/search-providers-catalog.test.ts index 2b7ca1c249..3b87481f13 100644 --- a/tests/integration/search-providers-catalog.test.ts +++ b/tests/integration/search-providers-catalog.test.ts @@ -2,7 +2,7 @@ * Integration tests for GET /api/search/providers — extended catalog (F4). * * Tests: - * - Returns 18 items total (14 search + 4 fetch providers). + * - Returns 19 items total (15 search + 4 fetch providers). * - Each item carries the correct `kind` field. * - Status reflects actual DB credential state: * - "configured" when an active, non-rate-limited connection exists. @@ -48,10 +48,10 @@ const route = await import("../../src/app/api/search/providers/route.ts"); // Constants // --------------------------------------------------------------------------- -// 14 search-kind providers: serper, brave, perplexity, exa, tavily, firecrawl, -// google-pse, linkup, searchapi, youcom, searxng, ollama, zai + duckduckgo-free -// (registry open-sse/config/searchRegistry.ts). -const EXPECTED_SEARCH_COUNT = 14; +// 15 search-kind providers: serper, brave, perplexity, exa, tavily, firecrawl, +// google-pse, linkup, searchapi, youcom, searxng, ollama, zai, jina-search + +// duckduckgo-free (registry open-sse/config/searchRegistry.ts). +const EXPECTED_SEARCH_COUNT = 15; const EXPECTED_FETCH_COUNT = 4; const EXPECTED_TOTAL = EXPECTED_SEARCH_COUNT + EXPECTED_FETCH_COUNT; @@ -307,7 +307,7 @@ test("search-providers-catalog: fetch providers have correct metadata", async () ); const jina = fetchProviders.find((p: { id: string }) => p.id === "jina-reader"); - assert.equal(jina.name, "Jina Reader"); + assert.equal(jina.name, "Jina Reader (r.jina.ai)"); assert.equal(jina.costPerQuery, 0.0005); assert.ok(jina.fetchFormats.includes("text"), "jina fetchFormats must include text"); diff --git a/tests/integration/upstream-cli-smoke.int.test.ts b/tests/integration/upstream-cli-smoke.int.test.ts new file mode 100644 index 0000000000..298e582fe5 --- /dev/null +++ b/tests/integration/upstream-cli-smoke.int.test.ts @@ -0,0 +1,190 @@ +/** + * Opt-in REAL smoke harness for upstream CLIs launched through `omniroute run`. + * + * Deterministic regression for the launch plans lives in + * `tests/unit/cli/run-command.test.ts` (dry-run plans) and + * `tests/unit/cli/run-execution.test.ts` (child-process isolation). This file + * exercises the REAL binaries against a REAL OmniRoute server and therefore: + * + * - NEVER runs automatically: every sub-test skips unless RUN_CLI_SMOKE=1; + * - NEVER ships or prints credentials: the API key is passed by env-var NAME + * (`--api-key-env`), values are never logged, and assertions only inspect + * exit codes and redacted output classes; + * - classifies failures as binary-missing / server-unreachable / auth / + * upstream instead of a bare boolean. + * + * Operator usage (all knobs are env vars — no secrets on the command line): + * + * RUN_CLI_SMOKE=1 \ + * OMNIROUTE_SMOKE_BASE_URL="http://localhost:20128" \ + * OMNIROUTE_SMOKE_MODEL="" \ + * OMNIROUTE_SMOKE_API_KEY_ENV="OMNIROUTE_API_KEY" \ + * node --import tsx/esm --test tests/integration/upstream-cli-smoke.int.test.ts + * + * Optional: OMNIROUTE_SMOKE_TARGETS="codex,opencode,qwen" restricts the sweep; + * OMNIROUTE_SMOKE_TIMEOUT_MS overrides the per-target timeout (default 120s). + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawn, execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const ENABLED = process.env.RUN_CLI_SMOKE === "1"; +const BASE_URL = (process.env.OMNIROUTE_SMOKE_BASE_URL || "http://localhost:20128").replace( + /\/+$/, + "" +); +const MODEL = process.env.OMNIROUTE_SMOKE_MODEL || ""; +const API_KEY_ENV = process.env.OMNIROUTE_SMOKE_API_KEY_ENV || "OMNIROUTE_API_KEY"; +const TIMEOUT_MS = Number(process.env.OMNIROUTE_SMOKE_TIMEOUT_MS || 120_000); + +const CLI_ENTRY = fileURLToPath(new URL("../../bin/omniroute.mjs", import.meta.url)); + +/** One-shot, non-interactive invocation per target. Prompts are inert. */ +const SMOKE_TARGETS: Record = { + codex: { args: ["exec", "--skip-git-repo-check", "reply with the single word OK"] }, + aider: { args: ["--message", "reply with the single word OK", "--no-git", "--yes-always"] }, + goose: { args: ["run", "-t", "reply with the single word OK"] }, + opencode: { args: ["run", "reply with the single word OK"] }, + qwen: { args: ["-p", "reply with the single word OK"] }, + gemini: { args: ["--skip-trust", "-p", "reply with the single word OK"] }, +}; + +function selectedTargets(): string[] { + const filter = String(process.env.OMNIROUTE_SMOKE_TARGETS || "") + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + const all = Object.keys(SMOKE_TARGETS); + return filter.length ? all.filter((t) => filter.includes(t)) : all; +} + +function binaryAvailable(target: string): boolean { + try { + execFileSync("sh", ["-c", 'command -v -- "$1"', "sh", target], { + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + }); + return true; + } catch { + return false; + } +} + +async function serverReachable(): Promise { + try { + const res = await fetch(`${BASE_URL}/api/monitoring/health`, { + signal: AbortSignal.timeout(5000), + }); + return res.ok; + } catch { + return false; + } +} + +/** Redact anything that looks like a secret before recording output. */ +function redact(text: string): string { + return text + .replace(/(sk|pk|rk)[-_][A-Za-z0-9_-]{8,}/g, "[redacted-key]") + .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [redacted]") + .slice(0, 2000); +} + +type SmokeResult = { + exitCode: number | null; + stdout: string; + stderr: string; + classification: "pass" | "auth" | "upstream" | "config" | "unknown"; +}; + +function classify(exitCode: number | null, output: string): SmokeResult["classification"] { + if (exitCode === 0) return "pass"; + if (/401|403|unauthorized|invalid[_ ]api[_ ]key/i.test(output)) return "auth"; + if (/5\d\d|upstream|overloaded|rate.?limit|429/i.test(output)) return "upstream"; + if (/not found|unknown model|unsupported|invalid (option|argument)/i.test(output)) { + return "config"; + } + return "unknown"; +} + +function runSmoke(target: string): Promise { + const spec = SMOKE_TARGETS[target]; + const args = [ + CLI_ENTRY, + "run", + target, + "--base-url", + BASE_URL, + "--api-key-env", + API_KEY_ENV, + ...(MODEL ? ["--model", MODEL] : []), + "--", + ...spec.args, + ]; + + return new Promise((resolve) => { + const child = spawn(process.execPath, args, { + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (c) => (stdout += String(c))); + child.stderr.on("data", (c) => (stderr += String(c))); + const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS); + // Resolve on "exit", not "close": some CLIs leave grandchildren holding the + // stdio pipes after the parent dies, and "close" would wait on them forever. + child.on("exit", (code) => { + clearTimeout(timer); + setTimeout(() => { + const combined = redact(stdout + "\n" + stderr); + resolve({ + exitCode: code, + stdout: redact(stdout), + stderr: redact(stderr), + classification: classify(code, combined), + }); + }, 250); // small grace period to flush buffered output + }); + }); +} + +// NOTE: node:test treats `timeout: 0` as "time out immediately", not "no +// timeout" — size the budget from the per-target cap instead. +const SWEEP_TIMEOUT_MS = (Object.keys(SMOKE_TARGETS).length + 1) * (TIMEOUT_MS + 30_000); + +test( + "upstream CLI smoke sweep (opt-in via RUN_CLI_SMOKE=1)", + { timeout: SWEEP_TIMEOUT_MS }, + async (t) => { + if (!ENABLED) { + t.skip("RUN_CLI_SMOKE!=1 — real smoke is operator opt-in, never automatic"); + return; + } + assert.ok(MODEL, "OMNIROUTE_SMOKE_MODEL must name the provider/model to exercise"); + assert.ok( + process.env[API_KEY_ENV] !== undefined, + `credential env var '${API_KEY_ENV}' must exist (value is never printed)` + ); + assert.ok(await serverReachable(), `OmniRoute is not reachable at ${BASE_URL}`); + + for (const target of selectedTargets()) { + await t.test(`smoke: ${target}`, async (st) => { + if (!binaryAvailable(target)) { + st.skip(`binary '${target}' not installed on this machine`); + return; + } + const result = await runSmoke(target); + st.diagnostic(`${target}: exit=${result.exitCode} class=${result.classification}`); + assert.equal( + result.classification, + "pass", + `${target} smoke failed (exit=${result.exitCode}, class=${result.classification}).\n` + + `stderr (redacted): ${result.stderr.slice(0, 500)}` + ); + }); + } + } +); diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 9d6a45573b..917bc7d086 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -1196,6 +1196,29 @@ "stream": "https://api.cloudflare.com/client/v4/accounts" } }, + "cloudflare-playground": { + "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": "https://playground.ai.cloudflare.com", + "stream": "https://playground.ai.cloudflare.com" + } + }, "clova-studio": { "format": "openai", "headers": { @@ -3625,29 +3648,6 @@ "stream": "https://api.llama.com/compat/v1/chat/completions" } }, - "mimocode": { - "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": "https://api.xiaomimimo.com", - "stream": "https://api.xiaomimimo.com" - } - }, "minimax": { "format": "openai", "headers": { diff --git a/tests/unit/10017-sse-control-lines-leak-openai-clients.test.ts b/tests/unit/10017-sse-control-lines-leak-openai-clients.test.ts new file mode 100644 index 0000000000..8e3e4f9495 --- /dev/null +++ b/tests/unit/10017-sse-control-lines-leak-openai-clients.test.ts @@ -0,0 +1,195 @@ +/** + * Regression test for #10017. + * + * In "Standard passthrough mode" (source format === client format, no + * translation needed) OmniRoute buffers upstream SSE control lines + * (`id:`, `event:`, `retry:`, bare `:` comments) via + * `createSSEEventPrefixBuffer()` and re-prepends them verbatim onto the next + * `data:` chunk. For a plain OpenAI Chat-Completions-format client + * (`clientResponseFormat === FORMATS.OPENAI`), that leaks literal `id: 0`, + * `event: done`, `: proxy-internal-metadata` lines into the client stream — a + * protocol OpenAI's own Chat-Completions SSE never uses. + * + * The fix is format-scoped: + * - `id:` / `retry:` / bare `:` comment lines are never forwarded for ANY + * client format (they are not part of Chat-Completions, Responses, or + * Claude Messages SSE). + * - `event:` framing is preserved ONLY for the protocols that define it + * (`FORMATS.OPENAI_RESPONSES` and `FORMATS.CLAUDE`); it is dropped for + * plain OpenAI Chat-Completions clients. + */ + +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-10017-sse-control-")); +process.env.DATA_DIR = TEST_DATA_DIR; +const core = await import("../../src/lib/db/core.ts"); + +const { createSSEStream } = await import("../../open-sse/utils/stream.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +const textEncoder = new TextEncoder(); + +async function readTransformed(chunks: string[], options: object): Promise { + const source = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(textEncoder.encode(chunk)); + } + controller.close(); + }, + }); + return new Response(source.pipeThrough(createSSEStream(options))).text(); +} + +test.after(() => { + core.resetDbInstance(); + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } +}); + +/** Returns raw upstream SSE control lines that leaked into the output. */ +function leakedUpstreamControlLines(output: string): string[] { + return output + .trim() + .split("\n") + .filter( + (l) => /^(?:id:|event:|retry:)/i.test(l) || (l.startsWith(":") && !l.startsWith(": x-omniroute-")) + ); +} + +test("#10017: OpenAI Chat-Completions passthrough drops upstream id/event/retry/comment control lines", async () => { + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_x", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello " } }], + })}\n`, + `id: 0\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_x", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { content: "world" } }], + })}\n`, + `id: 1\n\n`, + `event: done\n`, + `: proxy-internal-metadata\n\n`, + `data: [DONE]\n\n`, + ], + { + mode: "passthrough", + provider: "test-provider", + model: "gpt-4.1-mini", + clientResponseFormat: FORMATS.OPENAI, + body: { messages: [{ role: "user", content: "hello" }] }, + } + ); + + const leaked = leakedUpstreamControlLines(text); + assert.deepEqual( + leaked, + [], + `expected NO raw upstream SSE control lines forwarded to an OpenAI-format client, got: ${JSON.stringify(leaked)}` + ); +}); + +test("#10017: OpenAI Chat-Completions passthrough still delivers data chunks and [DONE]", async () => { + const text = await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl_x", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello " } }], + })}\n`, + `id: 0\n\n`, + `event: done\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_x", + object: "chat.completion.chunk", + created: 1, + model: "gpt-4.1-mini", + choices: [{ index: 0, delta: { content: "world" } }], + })}\n\n`, + `data: [DONE]\n\n`, + ], + { + mode: "passthrough", + provider: "test-provider", + model: "gpt-4.1-mini", + clientResponseFormat: FORMATS.OPENAI, + body: { messages: [{ role: "user", content: "hello" }] }, + } + ); + + assert.ok(text.includes("Hello "), "first data chunk must be forwarded"); + assert.ok(text.includes("world"), "second data chunk must be forwarded"); + assert.ok(text.includes("data: [DONE]"), "[DONE] must be forwarded"); +}); + +test("#10017: OpenAI Responses passthrough KEEPS event framing (regression guard for #6561 contract)", async () => { + const text = await readTransformed( + [ + `event: response.created\n`, + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_10017", output: [] } })}\n\n`, + `id: 1\n`, + `: proxy-internal-metadata\n\n`, + `event: response.output_text.delta\n`, + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "hi" })}\n\n`, + `event: response.completed\n`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_10017", ouput: [] } })}\n\n`, + ], + { + mode: "passthrough", + provider: "test-provider", + model: "gpt-4.1-mini", + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + body: { input: "hello" }, + } + ); + + const lines = text.trim().split("\n"); + assert.ok(lines.includes("event: response.created"), "Responses event framing must be preserved"); + assert.ok( + lines.includes("event: response.output_text.delta"), + "Responses output_text.delta event framing must be preserved" + ); + assert.ok( + !lines.some((l) => l.startsWith("id:") || (l.startsWith(":") && !l.startsWith(": x-omniroute-"))), + "Responses passthrough must still strip id:/comment control lines" + ); +}); + +test("#10017: Claude Messages passthrough KEEPS event framing", async () => { + const text = await readTransformed( + [ + `event: message_start\n`, + `data: ${JSON.stringify({ type: "message_start", message: { id: "msg_10017", type: "message", role: "assistant", content: [], model: "claude-3-5-sonnet" } })}\n\n`, + `event: content_block_delta\n`, + `data: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } })}\n\n`, + `event: message_stop\n\n`, + ], + { + mode: "passthrough", + provider: "anthropic", + model: "claude-3-5-sonnet-20241022", + clientResponseFormat: FORMATS.CLAUDE, + body: { messages: [{ role: "user", content: "hi" }] }, + } + ); + + const lines = text.trim().split("\n"); + assert.ok(lines.includes("event: message_start"), "Claude event framing must be preserved"); + assert.ok(lines.includes("event: content_block_delta"), "Claude delta event framing must be preserved"); +}); \ No newline at end of file diff --git a/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts b/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts new file mode 100644 index 0000000000..cf1a72ad7e --- /dev/null +++ b/tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts @@ -0,0 +1,243 @@ +/** + * #10085 -- a custom openai-compatible provider connection persisted under the + * GENERIC derived type id ("openai-compatible-chat") must still be reachable + * when the chat path looks up the concrete uuid node id + * ("openai-compatible-chat-"), and vice versa. + * + * `resolveProviderNodeForConnection` (src/lib/db/providers/nodes.ts, #4421) + * already accepts the bare generic type id when a connection is created via + * `/api/providers`. But `getProviderSearchPool` (src/sse/services/auth.ts) + * only bridged the search pool via a node's `prefix`, never via the generic + * type id <-> concrete node id relationship, so a connection created under + * the generic type id went permanently unreachable from the chat path -- + * "No active credentials for provider: openai-compatible-chat-", the + * exact error reported in #10085. + */ +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-10085-compat-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const nodesDb = await import("../../src/lib/db/providers/nodes.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); + +const NODE_PREFIX = "my-compat-10085"; +const NODE_ID = `openai-compatible-chat-458d982b-0000-4000-8000-000000000000`; +const NODE_B_ID = `openai-compatible-chat-558d982b-0000-4000-8000-000000000000`; + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function seedNode() { + await nodesDb.createProviderNode({ + id: NODE_ID, + type: "openai-compatible", + name: "My Compat", + prefix: NODE_PREFIX, + apiType: "chat", + baseUrl: "https://example.test/v1", + }); +} + +async function seedSecondNode() { + await nodesDb.createProviderNode({ + id: NODE_B_ID, + type: "openai-compatible", + name: "My Compat B", + prefix: "my-compat-10085-b", + apiType: "chat", + baseUrl: "https://example-b.test/v1", + }); +} + +test("a connection stored under the GENERIC type id is reachable when chat resolves the uuid node id (#10085)", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: "openai-compatible-chat", // generic type id, NOT the uuid node id + authType: "apikey", + apiKey: "sk-test-10085", + name: "test-compat", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + const creds = await auth.getProviderCredentials(NODE_ID); + + assert.ok( + creds, + `chat looked up "${NODE_ID}" but the connection is parked under the generic ` + + `"openai-compatible-chat" provider id -- getProviderSearchPool never bridges the ` + + `generic type id to the concrete node id. This matches #10085 exactly.` + ); +}); + +test("the bridge works in the other direction too: a uuid-stored connection is reachable via the generic type id", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: NODE_ID, // concrete uuid node id + authType: "apikey", + apiKey: "sk-test-10085-b", + name: "test-compat-b", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + const creds = await auth.getProviderCredentials("openai-compatible-chat"); + + assert.ok( + creds, + `a connection stored under the uuid node id "${NODE_ID}" must also be reachable via ` + + `a lookup using the bare generic type id "openai-compatible-chat"` + ); +}); + +test("a concrete second node does not inherit the first node's generic credentials", async () => { + await resetStorage(); + await seedNode(); + await seedSecondNode(); + await providersDb.createProviderConnection({ + provider: "openai-compatible-chat", + authType: "apikey", + apiKey: "sk-test-10085-node-a", + name: "test-compat-node-a", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { + nodeId: NODE_ID, + prefix: NODE_PREFIX, + baseUrl: "https://example-a.test/v1", + }, + }); + + const creds = await auth.getProviderCredentials(NODE_B_ID); + + assert.equal( + creds, + null, + "node B must not receive node A's generic connection when both nodes share a type" + ); +}); + +test("control: a connection stored under the uuid node id is found by a uuid node id lookup", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + apiKey: "sk-test-10085-c", + name: "test-compat-c", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + assert.ok(await auth.getProviderCredentials(NODE_ID)); +}); + +test("control: a connection stored under the uuid node id is found via prefix lookup", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + apiKey: "sk-test-10085-d", + name: "test-compat-d", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + assert.ok(await auth.getProviderCredentials(NODE_PREFIX)); +}); + +test("the bridge does not make unrelated generic types findable", async () => { + await resetStorage(); + await seedNode(); + await providersDb.createProviderConnection({ + provider: "openai-compatible-chat", + authType: "apikey", + apiKey: "sk-test-10085-e", + name: "test-compat-e", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + // A different generic type (responses, not chat) must stay unrelated. + assert.equal(await auth.getProviderCredentials("openai-compatible-responses"), null); +}); + +// #10434 -- the ambiguity guard added for #10085 was only applied to the +// concrete-id -> generic-type direction (`getProviderSearchPool`'s first +// bridging branch). The generic-type -> concrete-id direction (second +// branch) added every node sharing the derived type to the search pool +// UNCONDITIONALLY, with no ambiguity check. `selectProviderNodeForConnection` +// (src/lib/db/providerNodeSelect.ts, #4421) already established the +// project-wide rule for this exact generic-type fallback: "only when exactly +// one such node exists, so an ambiguous type never silently picks the wrong +// node". `getProviderSearchPool` must apply that SAME rule symmetrically in +// both directions -- otherwise a bare generic-type lookup (e.g. resolved by +// some caller without a concrete node id) silently pools in a connection +// that is scoped to one specific node's baseUrl/headers, sending traffic to +// the wrong upstream with the wrong credentials whenever a second node of +// the same generic type exists. +test( + "a bare generic-type lookup must not leak a node-scoped connection when the " + + "type is ambiguous across multiple nodes (#10434)", + async () => { + await resetStorage(); + await seedNode(); + await seedSecondNode(); + // Connection is scoped to node A specifically (stored under A's concrete + // uuid id, with A's own baseUrl) -- NOT under the bare generic type. + await providersDb.createProviderConnection({ + provider: NODE_ID, + authType: "apikey", + apiKey: "sk-test-10434-node-a", + name: "test-compat-10434-node-a", + isActive: true, + testStatus: "active", + priority: 1, + providerSpecificData: { prefix: NODE_PREFIX, baseUrl: "https://example.test/v1" }, + }); + + // A lookup by the BARE generic type (no concrete node id) must not + // resolve to node A's connection: two nodes (A and B) share the derived + // type "openai-compatible-chat", so the generic type is ambiguous and + // must not silently pick node A's credentials/baseUrl. + const creds = await auth.getProviderCredentials("openai-compatible-chat"); + + assert.equal( + creds, + null, + "a bare generic-type lookup resolved to node A's node-scoped connection even " + + "though the type is ambiguous (node B also derives 'openai-compatible-chat') -- " + + "this can route a request meant for a different node through node A's baseUrl " + + "and credentials." + ); + } +); diff --git a/tests/unit/9568-gemini-tool-casing-mismatch.test.ts b/tests/unit/9568-gemini-tool-casing-mismatch.test.ts index 854fe77f41..b1bc8d48d6 100644 --- a/tests/unit/9568-gemini-tool-casing-mismatch.test.ts +++ b/tests/unit/9568-gemini-tool-casing-mismatch.test.ts @@ -12,7 +12,7 @@ function flatten(items) { // ── Gemini -> OpenAI tool name casing fix (#9568) ────────────────────── -test("gemini-to-openai: no toolNameMap — Gemini returns lowercase 'bash', translator outputs 'bash' (bug)", () => { +test("gemini-to-openai: no toolNameMap — Gemini returns lowercase 'bash', translator outputs 'bash' (bug, unaffected by #10392 — that PR's static casing map applies only to the gemini-to-claude and openai-to-claude Claude Messages API paths, not this OpenAI-compatible passthrough path)", () => { const state = { toolCalls: new Map(), toolNameMap: null }; const result = geminiToOpenAIResponse( { @@ -75,7 +75,7 @@ test("gemini-to-openai: toolNameMap has lowercase alias — Gemini returns 'bash // ── Gemini -> Claude tool name casing fix (#9568) ────────────────────── -test("gemini-to-claude: no toolNameMap — Gemini returns 'bash', translator outputs 'bash' (bug)", () => { +test("gemini-to-claude: no toolNameMap — Gemini returns 'bash', translator outputs 'Bash' (#10392 consolidated fix)", () => { const state = {}; const result = geminiToClaudeResponse( { @@ -100,8 +100,8 @@ test("gemini-to-claude: no toolNameMap — Gemini returns 'bash', translator out const toolUse = result.find((c) => c.type === "content_block_start"); assert.equal( toolUse?.content_block?.name, - "bash", - "Without toolNameMap, lowercase tool name should pass through as-is (gemini-to-claude)" + "Bash", + "Without toolNameMap, restoreClaudeToolName's static casing map now normalizes known lowercase tool names to canonical PascalCase (gemini-to-claude, #10392 closes this permanently)" ); }); diff --git a/tests/unit/a2a-tasks-auth.test.ts b/tests/unit/a2a-tasks-auth.test.ts new file mode 100644 index 0000000000..5569905d66 --- /dev/null +++ b/tests/unit/a2a-tasks-auth.test.ts @@ -0,0 +1,91 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const TASKS_ROUTE = path.resolve(__dirname, "../../src/app/api/a2a/tasks/route.ts"); +const A2A_ROUTE = path.resolve(__dirname, "../../src/app/a2a/route.ts"); + +const source = fs.readFileSync(TASKS_ROUTE, "utf-8"); + +const { tokensMatch, authenticateA2A } = await import("../../src/app/api/a2a/tasks/route.ts"); + +function hasImport(src: string, name: string, from: string): boolean { + const pattern = new RegExp( + `import\\s+\\{[^}]*\\b${name}\\b[^}]*\\}\\s+from\\s+["']${from}["']` + ); + return pattern.test(src); +} + +test("tasks route uses the same constant-time contract as src/app/a2a/route.ts", () => { + const a2aSource = fs.readFileSync(A2A_ROUTE, "utf-8"); + assert.ok( + hasImport(a2aSource, "timingSafeEqual", "node:crypto"), + "reference route imports timingSafeEqual" + ); + + assert.ok( + hasImport(source, "timingSafeEqual", "node:crypto"), + "tasks route imports timingSafeEqual" + ); + assert.ok( + /\btokensMatch\s*\(\s*token\s*,\s*configuredKey\s*\)/.test(source), + "tasks route authenticates with tokensMatch(token, configuredKey)" + ); + assert.ok( + !/return\s+token\s*===\s*configuredKey\s*;/.test(source), + "tasks route no longer uses a plain === bearer compare" + ); +}); + +test("tokensMatch behaves like the helper in src/app/a2a/route.ts", () => { + assert.equal(tokensMatch("omniroute-a2a-test-key", "omniroute-a2a-test-key"), true); + assert.equal( + tokensMatch("x".repeat("omniroute-a2a-test-key".length), "omniroute-a2a-test-key"), + false, + "same-length different token is rejected" + ); + assert.equal(tokensMatch("", "omniroute-a2a-test-key"), false, "empty token is rejected"); + assert.equal( + tokensMatch("short", "omniroute-a2a-test-key"), + false, + "different-length token is rejected without throwing" + ); +}); + +test("authenticateA2A preserves the documented semantics", () => { + const API_KEY = "omniroute-a2a-test-key"; + + function makeRequest(token?: string): Request { + return { + headers: { + get(name: string) { + if (name.toLowerCase() !== "authorization") return null; + return token === undefined ? null : `Bearer ${token}`; + }, + }, + } as unknown as Request; + } + + delete process.env.OMNIROUTE_API_KEY; + assert.equal( + authenticateA2A(makeRequest()), + true, + "when OMNIROUTE_API_KEY is not set the route is open" + ); + + process.env.OMNIROUTE_API_KEY = API_KEY; + assert.equal(authenticateA2A(makeRequest(API_KEY)), true, "a valid bearer token passes auth"); + assert.equal( + authenticateA2A(makeRequest("x".repeat(API_KEY.length))), + false, + "a same-length but different token is rejected" + ); + assert.equal(authenticateA2A(makeRequest("")), false, "an empty bearer token is rejected"); + + delete process.env.OMNIROUTE_API_KEY; +}); diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index 5bd6ca24a8..5bacfbe8fd 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -1,11 +1,29 @@ 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"; + +// #10460: DATA_DIR must be assigned BEFORE any transitive DB import. The +// accountFallback.ts import below statically imports `@/lib/db/providers`, which +// imports `src/lib/db/core.ts`, whose `DATA_DIR` is a top-level +// `export const DATA_DIR = resolveWritableDataDir(...)` captured once at module-load +// time. Setting `process.env.DATA_DIR` after that first import is a no-op — the DB +// singleton keeps whatever DATA_DIR it resolved at import time, so an isolated test +// directory assigned later is silently never used and the #10460 tests below would +// actually read/write the shared default DATA_DIR instead. +const TEST_DATA_DIR_10460 = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-10460-")); +process.env.DATA_DIR = TEST_DATA_DIR_10460; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "10460-test-secret"; const accountFallback = await import("../../open-sse/services/accountFallback.ts"); const accountSelector = await import("../../open-sse/services/accountSelector.ts"); const { RateLimitReason, COOLDOWN_MS, PROVIDER_PROFILES } = await import("../../open-sse/config/constants.ts"); const { getCircuitBreaker } = await import("../../src/shared/utils/circuitBreaker.ts"); +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); const { isOAuthInvalidToken, @@ -1573,3 +1591,382 @@ test("isAccountDeactivated matches a custom signal after setCustomBannedSignals" setCustomBannedSignals([]); // cleanup — restore module state for other tests }); + +// ─── #10460: model-unsupported 400 skips account rotation ──────────────────── +// TEST_DATA_DIR_10460 / DATA_DIR / core / providersDb / auth are set up at the top +// of this file, BEFORE the accountFallback.ts import — see the comment there. + +async function resetStorage10460() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR_10460, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR_10460, { recursive: true }); +} + +async function seedConn10460(provider: string): Promise { + const conn = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + apiKey: `${provider}-key-10460`, + isActive: true, + testStatus: "active", + }); + return (conn as Record).id as string; +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR_10460, { recursive: true, force: true }); +}); + +test("#10460: model-unsupported 400 returns shouldFallback:false (no account cooldown)", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + const result = await auth.markAccountUnavailable( + connId, + 400, + "The requested model is not supported", + "github", + "claude-fable-5" + ); + + // The guard must prevent account cooldown — the error belongs to the combo layer + assert.strictEqual(result.shouldFallback, false, "must not trigger account rotation"); + assert.strictEqual(result.cooldownMs, 0, "must not cool down the account"); + + // Verify the connection was NOT marked unavailable + const after = await providersDb.getProviderConnectionById(connId); + assert.ok(!after.rateLimitedUntil, "connection must not be rate-limited"); + assert.notStrictEqual(after.testStatus, "unavailable", "connection must stay active"); +}); + +test("#10460: model-unsupported 400 handles various phrasings", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + const phrasings = [ + "The requested model is not supported", + "model claude-fable-5 is not supported", + "invalid_request_error: model is not supported", + "unsupported model: gpt-9", + ]; + + for (const errorText of phrasings) { + await resetStorage10460(); + const id = await seedConn10460("github"); + const result = await auth.markAccountUnavailable(id, 400, errorText, "github", "test-model"); + assert.strictEqual(result.shouldFallback, false, `phrasing "${errorText}" must not rotate`); + // Verify connection stays healthy after each iteration + const conn = await providersDb.getProviderConnectionById(id); + assert.ok(!conn.rateLimitedUntil, `"${errorText}" must not rate-limit connection`); + assert.notStrictEqual(conn.testStatus, "unavailable", `"${errorText}" must not mark unavailable`); + } +}); + +test("#10460: body-specific 400 still goes through normal path (not blocked by model guard)", async () => { + await resetStorage10460(); + const connId = await seedConn10460("openai"); + + const result = await auth.markAccountUnavailable( + connId, + 400, + "Invalid message format: the request body is malformed", + "openai", + "gpt-4" + ); + + // Body-specific 400 does NOT match MODEL_ACCESS_DENIED_PATTERNS — normal path applies + assert.strictEqual(result.shouldFallback, true, "body-specific 400 must still allow fallback"); +}); + +test("#10460: non-400 status with model-unsupported text does NOT trigger guard", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + // 429 + model-unsupported text should go through normal path (guard only fires for status===400) + const result = await auth.markAccountUnavailable( + connId, + 429, + "The requested model is not supported", + "github", + "test-model" + ); + + assert.strictEqual(result.shouldFallback, true, "non-400 must not be short-circuited by model guard"); + // The key assertion: guard returns shouldFallback:false. If we get here with + // shouldFallback:true, the guard did NOT fire (correct behavior). +}); + +test("#10460: empty errorText with status 400 does NOT trigger guard", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + const result = await auth.markAccountUnavailable(connId, 400, "", "github", "test-model"); + + // Empty string matches no patterns — normal path applies + assert.strictEqual(result.shouldFallback, false, "empty errorText is generic 400 → no fallback"); +}); + +test("#10460: auth-credential 400 text does NOT match model-unsupported guard", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + // "Invalid API key provided for model gpt-4o" contains "model" but is NOT a + // model-unsupported error — it's a credential issue. The guard must not fire. + const result = await auth.markAccountUnavailable( + connId, + 400, + "Invalid API key provided for model gpt-4o", + "github", + "gpt-4o" + ); + + // This text does NOT match MODEL_ACCESS_DENIED_PATTERNS (verified by regex test) + // so it falls through to checkFallbackError which returns shouldFallback:false for generic 400 + assert.strictEqual(result.shouldFallback, false, "auth-credential 400 must not be caught by model guard"); + // The generic 400 path returns cooldownMs:0 — same as the guard, but the + // connection was NOT touched (no rateLimitedUntil set). This distinguishes + // it from the normal fallback path which would set a cooldown. + const conn = await providersDb.getProviderConnectionById(connId); + assert.ok(!conn.rateLimitedUntil, "generic 400 must not rate-limit connection"); +}); + +test("#10460: guard early return does not touch DB (distinguishes from normal path)", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + // Guard path: model-unsupported 400 → shouldFallback:false, cooldownMs:0, no DB change + const guardResult = await auth.markAccountUnavailable( + connId, 400, "The requested model is not supported", "github", "test-model" + ); + assert.strictEqual(guardResult.shouldFallback, false); + assert.strictEqual(guardResult.cooldownMs, 0); + const guardConn = await providersDb.getProviderConnectionById(connId); + assert.ok(!guardConn.rateLimitedUntil, "guard path must not touch DB"); + assert.strictEqual(guardConn.testStatus, "active", "guard path must keep connection active"); +}); + +test("#10460: returned result exposes a sanitized provider_model_unsupported reason", async () => { + await resetStorage10460(); + const connId = await seedConn10460("github"); + + const result = await auth.markAccountUnavailable( + connId, + 400, + "The requested model is not supported", + "github", + "claude-fable-5" + ); + + assert.strictEqual(result.shouldFallback, false); + assert.strictEqual( + (result as { reason?: string }).reason, + "provider_model_unsupported", + "the canonical sanitized reason must be exposed on the returned result, not only in logs" + ); +}); + +test("#10460: account-scoped permission/entitlement 400 keeps rotating (not misclassified as provider-wide unsupported)", async () => { + await resetStorage10460(); + const connIds: string[] = []; + for (let i = 0; i < 3; i++) { + connIds.push(await seedConn10460("github")); + } + let calls = 0; + + // Phrased so it matches the broader/ambiguous MODEL_ACCESS_DENIED_PATTERNS + // ("permission" ... "model") but is NOT an unambiguous provider-wide "model not + // supported" response — it reads as an account/key entitlement gap (e.g. this + // key's plan doesn't include this model), where a DIFFERENT account of the same + // provider may still have access. Rotation through all 3 accounts must continue, + // unlike the unambiguous "model is not supported" case above. + for (const connId of connIds) { + calls += 1; + const result = await auth.markAccountUnavailable( + connId, + 400, + "Your API key does not have permission to use model gpt-4o", + "github", + "gpt-4o" + ); + if (!result.shouldFallback) break; + } + + assert.equal( + calls, + 3, + "an account-scoped permission/entitlement 400 must keep rotating through all accounts, " + + "not be short-circuited by the provider-wide model-unsupported guard" + ); +}); + +test("#10460: account-scoped 401 keeps rotating through all 3 accounts (not short-circuited)", async () => { + await resetStorage10460(); + const connIds: string[] = []; + for (let i = 0; i < 3; i++) { + connIds.push(await seedConn10460("github")); + } + let calls = 0; + + for (const connId of connIds) { + calls += 1; + const result = await auth.markAccountUnavailable( + connId, + 401, + "Unauthorized: invalid credentials", + "github", + "claude-fable-5" + ); + if (!result.shouldFallback) break; + } + + assert.equal( + calls, + 3, + "401 account-scoped errors must keep rotating through every account, unlike model-unsupported 400" + ); +}); + +test("#10460: account-scoped 403 keeps rotating through all 3 accounts", async () => { + await resetStorage10460(); + const connIds: string[] = []; + for (let i = 0; i < 3; i++) { + connIds.push(await seedConn10460("github")); + } + let calls = 0; + + for (const connId of connIds) { + calls += 1; + const result = await auth.markAccountUnavailable( + connId, + 403, + "Forbidden: access denied for this account", + "github", + "claude-fable-5" + ); + if (!result.shouldFallback) break; + } + + assert.equal(calls, 3, "403 account-scoped errors must keep rotating through every account"); +}); + +test("#10460: 429 rate limit keeps rotating through all 3 accounts", async () => { + await resetStorage10460(); + const connIds: string[] = []; + for (let i = 0; i < 3; i++) { + connIds.push(await seedConn10460("github")); + } + let calls = 0; + + for (const connId of connIds) { + calls += 1; + const result = await auth.markAccountUnavailable( + connId, + 429, + "Rate limit exceeded", + "github", + "claude-fable-5" + ); + if (!result.shouldFallback) break; + } + + assert.equal(calls, 3, "429 rate-limit errors must keep rotating through every account"); +}); + +// ─── #10460 acceptance criteria: 3-account rotation + combo target advancement ─ +// +// Reproduces the exact regression from issue #10460: a combo with a +// (github/model, 3 accounts) target followed by a sibling target. When GitHub +// returns an unambiguous "model not supported" 400, the account-rotation loop +// must make exactly ONE upstream call (not one per account) and the combo must +// advance to the next target — not just that markAccountUnavailable() in +// isolation returns shouldFallback:false (covered above), but that a realistic +// rotation loop wired to the REAL markAccountUnavailable()/ +// isProviderModelUnsupported400() gating actually stops after account 1 and lets +// handleComboChat() move on. +// +// The inner loop below is a faithful, minimal reproduction of the account-rotation +// contract in src/sse/handlers/chat.ts::handleSingleModelChat step 8 ("Fallback to +// next account", ~line 1936): call markAccountUnavailable(); continue to the next +// connection only while shouldFallback is true, otherwise stop immediately and +// surface the failure. Reimplemented at this scope (rather than driving the full +// handleChat()/route stack) so the test can assert on upstream-call counts and +// per-connection DB state directly, while still exercising the real gating logic +// that decides whether rotation continues. +test("#10460 acceptance: unambiguous model-unsupported 400 makes exactly ONE upstream call across 3 accounts, then the combo advances to the next target", async () => { + await resetStorage10460(); + const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + + const githubConnIds: string[] = []; + for (let i = 0; i < 3; i++) { + githubConnIds.push(await seedConn10460("github")); + } + + let githubUpstreamCalls = 0; + const triedGithubConnections: string[] = []; + const noopLog = { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} }; + + const handleSingleModel = async (_body: unknown, modelStr: string) => { + if (modelStr.startsWith("github/")) { + for (const connId of githubConnIds) { + triedGithubConnections.push(connId); + githubUpstreamCalls += 1; + const result = await auth.markAccountUnavailable( + connId, + 400, + "The requested model is not supported", + "github", + "claude-fable-5" + ); + if (result.shouldFallback) continue; + return new Response( + JSON.stringify({ error: { message: "The requested model is not supported" } }), + { status: 400, headers: { "Content-Type": "application/json" } } + ); + } + // A test-fixture bug (guard not firing) would otherwise silently exhaust + // every account and mask the regression this test exists to catch. + throw new Error("all 3 github accounts were tried — the guard did not fire"); + } + // Second combo target (a different provider) — succeeds immediately. + return new Response(JSON.stringify({ id: "ok", choices: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }] }, + combo: { + name: "test-combo-10460", + strategy: "priority", + models: [{ model: "github/claude-fable-5" }, { model: "openai/gpt-4o-mini" }], + }, + handleSingleModel, + log: noopLog, + settings: {}, + allCombos: [], + }); + + assert.equal( + githubUpstreamCalls, + 1, + `expected exactly ONE upstream call for github/claude-fable-5 across 3 accounts, got ` + + `${githubUpstreamCalls} (tried: ${triedGithubConnections.join(", ")})` + ); + assert.equal(triedGithubConnections.length, 1, "only the first account should have been tried"); + assert.equal(result.status, 200, "the combo must advance to the next target and succeed"); + + // The two untouched accounts must remain completely unaffected — proves the + // guard did not just avoid an upstream call but also never cooled them down, + // so they stay immediately eligible for the next unrelated request. + for (const connId of githubConnIds.slice(1)) { + const conn = await providersDb.getProviderConnectionById(connId); + assert.ok(!conn.rateLimitedUntil, `untried account ${connId} must not be rate-limited`); + assert.notStrictEqual( + conn.testStatus, + "unavailable", + `untried account ${connId} must stay active` + ); + } +}); diff --git a/tests/unit/accountfallback-ratelimit-400-4976.test.ts b/tests/unit/accountfallback-ratelimit-400-4976.test.ts index e1de7b6f8c..d99854894e 100644 --- a/tests/unit/accountfallback-ratelimit-400-4976.test.ts +++ b/tests/unit/accountfallback-ratelimit-400-4976.test.ts @@ -18,14 +18,14 @@ test("#4976 400 with rate-limit text (MiMoCode) → fallback with RATE_LIMIT_EXC "Detected high-frequency non-compliant requests from you.", 0, null, - "mimocode" + "theoldllm" ); assert.equal(res.shouldFallback, true); assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); }); test("#4976 400 with Chinese rate-limit text → fallback with RATE_LIMIT_EXCEEDED", () => { - const res = checkFallbackError(400, "检测到您的请求频率过高,请稍后再试", 0, null, "mimocode"); + const res = checkFallbackError(400, "检测到您的请求频率过高,请稍后再试", 0, null, "theoldllm"); assert.equal(res.shouldFallback, true); assert.equal(res.reason, RateLimitReason.RATE_LIMIT_EXCEEDED); }); diff --git a/tests/unit/adaptive-admission-controller.test.ts b/tests/unit/adaptive-admission-controller.test.ts index bd0785c0a3..8dd1d61f87 100644 --- a/tests/unit/adaptive-admission-controller.test.ts +++ b/tests/unit/adaptive-admission-controller.test.ts @@ -862,17 +862,25 @@ describe("adaptive algorithm", () => { // Immediate fast decrease: 80 * 0.5 = 40. assert.equal(c.snapshot().currentLimit, 40); - // Closing the same window must not multiply again (would become 20). + // Closing the same window must not multiply again (would become 20). #10111 idle + // recovery may climb the collapsed limit upward on the subsequent idle window, so it + // can exceed 40 — the invariant is that closing the window never RE-decreases toward + // the multiplied 20, and recovery stays below the 80 ceiling. clock.advance(100); c.tick(); - assert.equal(c.snapshot().currentLimit, 40); + assert.ok(c.snapshot().currentLimit >= 40); + assert.ok(c.snapshot().currentLimit < 80); - // A fresh critical observation in a later window still decreases once. + // A fresh critical observation in a later window still decreases once — the immediate + // path applies an exact halving regardless of how far idle recovery had climbed first. + const beforeSecond = c.snapshot().currentLimit; c.observePressure("critical"); - assert.equal(c.snapshot().currentLimit, 20); + assert.equal(c.snapshot().currentLimit, Math.floor(beforeSecond / 2)); + const secondFloor = c.snapshot().currentLimit; clock.advance(100); c.tick(); - assert.equal(c.snapshot().currentLimit, 20); + assert.ok(c.snapshot().currentLimit >= secondFloor); + assert.ok(c.snapshot().currentLimit < 80); }); it("decreases on high pressure or sustained latency gradient", async () => { @@ -971,7 +979,13 @@ describe("adaptive algorithm", () => { assert.ok(afterObservedWindow < 80); clock.advance(500); - assert.equal(c.snapshot().currentLimit, afterObservedWindow); + // Stale latency/pressure evidence is still consumed only in its observed window and + // never re-applied as a further decrease (the limit does not drop below + // afterObservedWindow). #10111 idle-recovery instead CLIMBS the collapsed limit back + // toward the recovery ceiling on sustained idle windows, so it recovers upward while + // staying below the initial 80 ceiling. + assert.ok(c.snapshot().currentLimit >= afterObservedWindow); + assert.ok(c.snapshot().currentLimit < 80); }); }); diff --git a/tests/unit/adaptive-admission-latency-collapse.test.ts b/tests/unit/adaptive-admission-latency-collapse.test.ts new file mode 100644 index 0000000000..7b4c7ecc4c --- /dev/null +++ b/tests/unit/adaptive-admission-latency-collapse.test.ts @@ -0,0 +1,288 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + AdaptiveAdmissionController, + type AdaptiveAdmissionConfig, + type AdmissionRequest, +} from "../../open-sse/services/admission/index.ts"; + +/** + * Regression guard for #10111 — adaptive admission latency collapse. + * + * A slow-provider turn shrinks the adaptive aggregate limit below an ordinary request's + * cost; in enforce mode that request was rejected ADMISSION_OVERSIZED forever, and idle + * recovery could never fire because the only limit-increase path requires a completed + * admission (which can never happen once nothing can be admitted) — a self-lock. + * + * Fix: (1) solo-progress — an individually-valid request within the healthy aggregate + * ceiling admitted while the system is idle & normal pressure, and (2) idle recovery — + * sustained idle windows actively raise the collapsed limit back toward the recovery + * ceiling. Reproduced deterministically with the shipping defaults via a fake clock. + * + * Uses the exact repro harness from the triage plan (same FakeClock, same defaults). + */ +class FakeClock { + nowMs = 0; + private nextId = 1; + private timers = new Map void }>(); + now = () => this.nowMs; + setTimer = (fn: () => void, delayMs: number): number => { + const id = this.nextId++; + this.timers.set(id, { due: this.nowMs + Math.max(0, delayMs), fn }); + return id; + }; + clearTimer = (id: number): void => { this.timers.delete(id); }; + advance(ms: number): void { + const target = this.nowMs + ms; + while (true) { + let nextId: number | undefined; + let nextDue = Number.POSITIVE_INFINITY; + for (const [id, t] of this.timers) { + if (t.due <= target && t.due < nextDue) { nextDue = t.due; nextId = id; } + } + if (nextId === undefined) { this.nowMs = target; return; } + const timer = this.timers.get(nextId)!; + this.timers.delete(nextId); + this.nowMs = timer.due; + timer.fn(); + } + } +} + +function shippingDefaults(overrides: Partial = {}): AdaptiveAdmissionConfig { + return { + mode: "enforce", minLimit: 8, initialLimit: 64, maxLimit: 1000, + maxQueueCount: 128, maxQueueCost: 2000, defaultMaxWaitMs: 5_000, windowMs: 1_000, + decreaseFactor: 0.8, criticalDecreaseFactor: 0.5, increaseStep: 1, maxIncreasePerWindow: 1, + shortLatencyAlpha: 0.5, longLatencyAlpha: 0.1, + highUtilizationThreshold: 0.7, lowUtilizationThreshold: 0.3, latencyGradientThreshold: 0.25, + ...overrides, + }; +} +function req(cost: number): AdmissionRequest { return { tenantKey: "t-default", cost }; } + +const REQUEST_COST = 63; + +function newController(clock: FakeClock): AdaptiveAdmissionController { + return new AdaptiveAdmissionController(shippingDefaults(), { + now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer, + }); +} + +class Orchestrator { + controller: AdaptiveAdmissionController; + clock: FakeClock; + constructor() { + this.clock = new FakeClock(); + this.controller = newController(this.clock); + } + shutdown(): void { this.controller.shutdown(); } + + /** Admit+release one cost-63 request with the given end-to-end latency sample. */ + async ordinaryTurn(latencyMs: number): Promise { + const r = await this.controller.acquire(req(REQUEST_COST)); + assert.equal(r.status, "admitted"); + if (r.status === "admitted") { + this.clock.advance(1_000); + r.lease.release("success", { latencyMs }); + this.clock.advance(1_000); + } + } +} + +describe("#10111 — adaptive admission latency collapse", () => { + it("a slow-provider gradient collapse does not terminally reject an idle, individually-valid request (solo-progress)", async () => { + const env = new Orchestrator(); + try { + await env.ordinaryTurn(1_000); + await env.ordinaryTurn(10_000); + + const collapsed = env.controller.snapshot().currentLimit; + assert.ok(collapsed < REQUEST_COST, `expected limit collapsed below ${REQUEST_COST}, got ${collapsed}`); + assert.equal(env.controller.snapshot().activeCount, 0); + assert.equal(env.controller.snapshot().queuedCount, 0); + + // System idle & normal pressure: must make progress, not be terminally rejected. + const r3 = await env.controller.acquire(req(REQUEST_COST)); + assert.equal(r3.status, "admitted"); + if (r3.status === "admitted") r3.lease.release("success", { latencyMs: 100 }); + } finally { + env.shutdown(); + } + }); + + it("sustained idle windows actively recover the collapsed limit so normal requests re-enter", async () => { + const env = new Orchestrator(); + try { + await env.ordinaryTurn(1_000); + await env.ordinaryTurn(10_000); + + assert.ok(env.controller.snapshot().currentLimit < REQUEST_COST); + + // No work in flight; run 20 idle windows. + for (let i = 0; i < 20; i++) env.clock.advance(1_000); + + const recovered = env.controller.snapshot().currentLimit; + assert.ok( + recovered >= REQUEST_COST, + `expected idle recovery to restore the limit >= ${REQUEST_COST}, got ${recovered}` + ); + assert.equal(env.controller.snapshot().activeCount, 0); + assert.equal(env.controller.snapshot().queuedCount, 0); + + const r4 = await env.controller.acquire(req(REQUEST_COST)); + assert.equal(r4.status, "admitted"); + if (r4.status === "admitted") r4.lease.release("success", { latencyMs: 100 }); + } finally { + env.shutdown(); + } + }); + + it("critical pressure fuse still wins over solo-progress", async () => { + const env = new Orchestrator(); + try { + await env.ordinaryTurn(1_000); + await env.ordinaryTurn(10_000); + + assert.ok(env.controller.snapshot().currentLimit < REQUEST_COST); + env.controller.observePressure("critical"); + + const r = await env.controller.acquire(req(REQUEST_COST)); + assert.equal(r.status, "rejected"); + if (r.status === "rejected") assert.equal(r.code, "ADMISSION_OVERSIZED"); + } finally { + env.shutdown(); + } + }); + + it("solo-progress does not bypass the healthy aggregate ceiling (maxLimit) or run under load", async () => { + const env = new Orchestrator(); + try { + // A request beyond the healthy aggregate ceiling is still rejected even when idle. + // Distinct maxLimit (20) vs maxRequestCost (100): raw cost 50 is within the hard + // per-request ceiling but exceeds the aggregate ceiling → solo-progress must not + // admit it. + const ceilingGuard = new AdaptiveAdmissionController( + shippingDefaults({ minLimit: 8, initialLimit: 20, maxLimit: 20, cost: { maxRequestCost: 100 } }), + { now: env.clock.now, setTimer: env.clock.setTimer, clearTimer: env.clock.clearTimer } + ); + try { + const heavy = await ceilingGuard.acquire(req(50)); + assert.equal(heavy.status, "rejected"); + if (heavy.status === "rejected") assert.equal(heavy.code, "ADMISSION_OVERSIZED"); + } finally { + ceilingGuard.shutdown(); + } + + // A busy controller (active + queued work present): solo-progress must NOT admit a + // request over the limit — genuine load still sheds oversized-for-limit work. + const busy = new AdaptiveAdmissionController( + shippingDefaults({ minLimit: 8, initialLimit: 20, maxLimit: 20 }), + { now: env.clock.now, setTimer: env.clock.setTimer, clearTimer: env.clock.clearTimer } + ); + try { + const first = await busy.acquire(req(15)); + assert.equal(first.status, "admitted"); + const second = await busy.acquire(req(15)); + assert.equal(second.status, "queued"); + // Active + queued present → not idle → solo must not bypass; oversized rejected. + const over = await busy.acquire(req(25)); + assert.equal(over.status, "rejected"); + if (over.status === "rejected") assert.equal(over.code, "ADMISSION_OVERSIZED"); + if (first.status === "admitted") first.lease.release("success", { latencyMs: 1_000 }); + if (second.status === "queued") { (await second.promise).lease.release("success"); } + } finally { + busy.shutdown(); + } + } finally { + env.shutdown(); + } + }); +}); + +describe("#10111 — updateConfig refreshes the idle-recovery ceiling", () => { + it("a larger initialLimit raises the recovery ceiling, clamped to the (possibly new) maxLimit", () => { + const clock = new FakeClock(); + const controller = new AdaptiveAdmissionController( + shippingDefaults({ minLimit: 5, initialLimit: 20, maxLimit: 50, increaseStep: 1, maxIncreasePerWindow: 1 }), + { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer } + ); + try { + // Collapse the limit well below the original ceiling (20) via one critical hit. + controller.observePressure("critical"); // 20 -> floor(20 * 0.5) = 10 + assert.equal(controller.snapshot().currentLimit, 10); + // Close the window the critical hit landed in — idle recovery is suppressed for a + // window where pressure is still critical, so the limit stays put. + clock.advance(1_000); + assert.equal(controller.snapshot().currentLimit, 10); + + // Raise initialLimit far past the (unchanged) maxLimit, and widen the recovery step + // so a single idle window jumps straight to the new ceiling. + controller.updateConfig( + shippingDefaults({ + minLimit: 5, + initialLimit: 1_000, + maxLimit: 50, + increaseStep: 1_000, + maxIncreasePerWindow: 1_000, + }) + ); + + // Idle window: no active/queued work, pressure normal — idle recovery climbs + // straight to the recovery ceiling. + clock.advance(1_000); + assert.equal( + controller.snapshot().currentLimit, + 50, + "recoveryCeiling must track the raised initialLimit, clamped to maxLimit (50)" + ); + } finally { + controller.shutdown(); + } + }); + + it("a smaller initialLimit lowers the recovery ceiling, clamped to the (possibly new) minLimit", () => { + const clock = new FakeClock(); + const controller = new AdaptiveAdmissionController( + shippingDefaults({ minLimit: 5, initialLimit: 100, maxLimit: 200, increaseStep: 1, maxIncreasePerWindow: 1 }), + { now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer } + ); + try { + // Three separate critical-pressure windows collapse the limit well below the + // original ceiling (100): 100 -> 50 -> 25 -> 12. Each hit lands in its own window + // (advance closes it) so criticalDecreaseConsumed resets and the next hit re-fires. + controller.observePressure("critical"); // 100 -> 50 + clock.advance(1_000); + controller.observePressure("critical"); // 50 -> 25 + clock.advance(1_000); + controller.observePressure("critical"); // 25 -> 12 + clock.advance(1_000); + assert.equal(controller.snapshot().currentLimit, 12); + + // Lower initialLimit below the new minLimit, and widen the recovery step so a stale + // (unrefreshed) ceiling would be unmistakable: it would let idle recovery jump the + // limit straight back up to the old ceiling (100). + controller.updateConfig( + shippingDefaults({ + minLimit: 5, + initialLimit: 1, + maxLimit: 200, + increaseStep: 1_000, + maxIncreasePerWindow: 1_000, + }) + ); + + // Idle window: with the ceiling correctly refreshed to clampLimit(1, 5, 200) = 5, + // currentLimit (12) is already above the ceiling, so idle recovery must not grow it + // at all — in particular it must not climb back to the stale 100 ceiling. + clock.advance(1_000); + assert.equal( + controller.snapshot().currentLimit, + 12, + "recoveryCeiling must track the lowered initialLimit (clamped to minLimit), not the stale higher ceiling" + ); + } finally { + controller.shutdown(); + } + }); +}); \ No newline at end of file diff --git a/tests/unit/adaptive-circuit-budget-ledger.test.ts b/tests/unit/adaptive-circuit-budget-ledger.test.ts new file mode 100644 index 0000000000..3ea22a4829 --- /dev/null +++ b/tests/unit/adaptive-circuit-budget-ledger.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { observeCircuit, createAdaptiveCircuit } from "@/lib/resilience/adaptiveCircuit"; +import { evaluateBudget } from "@/lib/usage/budgetGuard"; +import { ModelPricingRegistry } from "@/lib/usage/modelPricingRegistry"; +import { createUsageRecord, summarizeUsage } from "@/lib/usage/usageLedger"; + +test("adaptive circuit opens, probes, and closes after recovery", () => { + const now = new Date("2026-01-01T00:00:00.000Z"); + let circuit = createAdaptiveCircuit(); + circuit = observeCircuit(circuit, "failure", { + now, + failureThreshold: 2, + cooldownMs: 1000, + reason: "timeout", + }); + circuit = observeCircuit(circuit, "failure", { + now: new Date(now.getTime() + 10), + failureThreshold: 2, + cooldownMs: 1000, + reason: "timeout", + }); + assert.equal(circuit.state, "open"); + circuit = observeCircuit(circuit, "probe", { now: new Date(now.getTime() + 1011) }); + assert.equal(circuit.state, "half_open"); + circuit = observeCircuit(circuit, "success", { now: new Date(now.getTime() + 1002) }); + assert.equal(circuit.state, "closed"); + assert.equal(circuit.failureCount, 0); +}); + +test("internal budget returns allow, warn, and deny without upstream quota claims", () => { + const limit = { + id: "b", + scope: "global" as const, + period: "daily" as const, + limitType: "currency" as const, + limitValue: 10, + warningThreshold: 0.75, + enabled: true, + }; + assert.equal(evaluateBudget(limit, { currency: 2, tokens: 0, requests: 0 }).decision, "allow"); + assert.equal(evaluateBudget(limit, { currency: 8, tokens: 0, requests: 0 }).decision, "warn"); + assert.equal(evaluateBudget(limit, { currency: 10, tokens: 0, requests: 0 }).decision, "deny"); +}); + +test("unknown pricing remains unknown while configured pricing is estimated", () => { + const registry = new ModelPricingRegistry(); + assert.equal(registry.estimate("codex", "unknown", 1000, 1000), undefined); + registry.set({ + providerId: "codex", + modelId: "gpt-5", + inputPricePerMillionTokens: 1, + outputPricePerMillionTokens: 2, + source: "admin", + }); + assert.equal(registry.estimate("codex", "gpt-5", 1000, 1000), 0.003); + const record = createUsageRecord( + { + id: "r1", + providerId: "codex", + modelId: "gpt-5", + inputTokens: 100, + outputTokens: 50, + latencyMs: 10, + status: "success", + createdAt: "2026-01-01T00:00:00.000Z", + }, + registry + ); + assert.equal(record.totalTokens, 150); + assert.equal(record.estimatedCostUsd, 0.0002); + const summary = summarizeUsage([record]); + assert.equal(summary.requests, 1); + assert.equal(summary.successes, 1); + assert.equal(summary.estimatedCostUsd, 0.0002); +}); diff --git a/tests/unit/admission-virtual-lanes-flag.test.ts b/tests/unit/admission-virtual-lanes-flag.test.ts new file mode 100644 index 0000000000..3b62dab254 --- /dev/null +++ b/tests/unit/admission-virtual-lanes-flag.test.ts @@ -0,0 +1,132 @@ +/** + * U7 (#9654 Wave 2): adaptive virtual-lanes feature flag — env-wins resolution + * and boot warm. + * + * Contract under test: + * - resolveAdaptiveVirtualLanesFlag: env (`"1"`|`"true"`) > DB override > + * default(false); env wins even when set to an explicit "off" value. + * - warmAdaptiveVirtualLanesIntoRuntime: only a DB-sourced state folds into + * the runtime env (`"1"`/`"0"`); env/default sources are no-ops. + * + * Run: bun test tests/unit/admission-virtual-lanes-flag.test.ts + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + ADAPTIVE_VIRTUAL_LANES_FLAG_KEY, + resolveAdaptiveVirtualLanesFlag, + warmAdaptiveVirtualLanesIntoRuntime, +} from "../../src/lib/admissionVirtualLanes.ts"; + +const emptyEnv = {}; +const noOverride = (): string | undefined => undefined; + +describe("resolveAdaptiveVirtualLanesFlag", () => { + it('env "1" enables and reports env, winning over a DB override', () => { + const state = resolveAdaptiveVirtualLanesFlag({ + env: { [ADAPTIVE_VIRTUAL_LANES_FLAG_KEY]: "1" }, + getOverride: () => "false", + }); + assert.deepEqual(state, { enabled: true, source: "env" }); + }); + + it('env "true" enables (runtime-compatible truthiness)', () => { + const state = resolveAdaptiveVirtualLanesFlag({ + env: { [ADAPTIVE_VIRTUAL_LANES_FLAG_KEY]: "true" }, + getOverride: noOverride, + }); + assert.deepEqual(state, { enabled: true, source: "env" }); + }); + + it('env "0" is an explicit off that still wins over the DB', () => { + const state = resolveAdaptiveVirtualLanesFlag({ + env: { [ADAPTIVE_VIRTUAL_LANES_FLAG_KEY]: "0" }, + getOverride: () => "true", + }); + assert.deepEqual(state, { enabled: false, source: "env" }); + }); + + it("DB override enables when env is absent", () => { + const state = resolveAdaptiveVirtualLanesFlag({ + env: emptyEnv, + getOverride: () => "true", + }); + assert.deepEqual(state, { enabled: true, source: "db" }); + }); + + it('DB override "1" enables when env is absent', () => { + const state = resolveAdaptiveVirtualLanesFlag({ + env: emptyEnv, + getOverride: () => "1", + }); + assert.deepEqual(state, { enabled: true, source: "db" }); + }); + + it("DB override disables when env is absent", () => { + const state = resolveAdaptiveVirtualLanesFlag({ + env: emptyEnv, + getOverride: () => "false", + }); + assert.deepEqual(state, { enabled: false, source: "db" }); + }); + + it("defaults to disabled when neither env nor DB is set", () => { + const state = resolveAdaptiveVirtualLanesFlag({ + env: emptyEnv, + getOverride: noOverride, + }); + assert.deepEqual(state, { enabled: false, source: "default" }); + }); +}); + +describe("warmAdaptiveVirtualLanesIntoRuntime", () => { + it('folds a DB-sourced enable into the runtime env as "1"', async () => { + let reloaded = false; + let foldedEnv: NodeJS.ProcessEnv | undefined; + const materialized = await warmAdaptiveVirtualLanesIntoRuntime({ + resolve: () => ({ enabled: true, source: "db" }), + reload: (options) => { + reloaded = true; + foldedEnv = options.env; + }, + }); + + assert.equal(materialized, true); + assert.equal(reloaded, true, "must reload the runtime when the DB is the source"); + assert.equal(foldedEnv?.[ADAPTIVE_VIRTUAL_LANES_FLAG_KEY], "1"); + }); + + it('folds a DB-sourced disable into the runtime env as "0"', async () => { + let foldedEnv: NodeJS.ProcessEnv | undefined; + const materialized = await warmAdaptiveVirtualLanesIntoRuntime({ + resolve: () => ({ enabled: false, source: "db" }), + reload: (options) => { + foldedEnv = options.env; + }, + }); + + assert.equal(materialized, true); + assert.equal(foldedEnv?.[ADAPTIVE_VIRTUAL_LANES_FLAG_KEY], "0"); + }); + + it("no-op when the source is env (operator env wins, nothing to fold)", async () => { + const materialized = await warmAdaptiveVirtualLanesIntoRuntime({ + resolve: () => ({ enabled: true, source: "env" }), + reload: () => { + throw new Error("must not reload when env is the source"); + }, + }); + assert.equal(materialized, false); + }); + + it("no-op when the source is default", async () => { + const materialized = await warmAdaptiveVirtualLanesIntoRuntime({ + resolve: () => ({ enabled: false, source: "default" }), + reload: () => { + throw new Error("must not reload when the default applies"); + }, + }); + assert.equal(materialized, false); + }); +}); diff --git a/tests/unit/agenticConversations.test.ts b/tests/unit/agenticConversations.test.ts new file mode 100644 index 0000000000..47e5fba6e2 --- /dev/null +++ b/tests/unit/agenticConversations.test.ts @@ -0,0 +1,299 @@ +/** + * Unit tests for src/lib/db/agenticConversations.ts CRUD. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-agentic-conv-db-")); +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "agentic-conversations-test-secret"; + +// Dynamic imports (not static) are required here: a static `import` of a module +// that reads process.env.DATA_DIR at its own top level (src/lib/db/core.ts's +// `export const DATA_DIR = ...`) is evaluated before this file's own top-level +// code runs — ESM instantiates the whole dependency graph, dependencies first, +// regardless of source-line order — so the override above would silently miss +// and the module would resolve the real host DATA_DIR instead of the temp dir. +const { + createAgenticConversation, + findAgenticConversationsByFingerprint, + updateAgenticConversation, + touchOrCreateExternalConversation, + listMultiTurnConversations, + getConversationTurnIndex, + insertConversationTurnNodes, + getConversationTurnPage, + resolveCallLogIdsByCorrelationIds, +} = await import("../../src/lib/db/agenticConversations.ts"); +const { getDbInstance } = await import("../../src/lib/db/core.ts"); + +test("createAgenticConversation + findAgenticConversationsByFingerprint round-trip", () => { + const row = createAgenticConversation({ + apiKeyId: "key-a", + fingerprintHash: "fp-round-trip", + }); + + assert.match(row.id, /^conv_/); + assert.equal(row.turnCount, 1); + + const found = findAgenticConversationsByFingerprint("fp-round-trip"); + assert.equal(found.length, 1); + assert.equal(found[0].id, row.id); + assert.equal(found[0].apiKeyId, "key-a"); +}); + +test("findAgenticConversationsByFingerprint returns multiple rows for a shared fingerprint", () => { + createAgenticConversation({ apiKeyId: "key-b", fingerprintHash: "fp-shared" }); + createAgenticConversation({ apiKeyId: "key-b", fingerprintHash: "fp-shared" }); + + const found = findAgenticConversationsByFingerprint("fp-shared"); + assert.equal(found.length, 2); +}); + +test("updateAgenticConversation updates turn count", () => { + const row = createAgenticConversation({ apiKeyId: "key-c", fingerprintHash: "fp-update" }); + + updateAgenticConversation(row.id, { turnCount: 3 }); + + const found = findAgenticConversationsByFingerprint("fp-update"); + assert.equal(found[0].turnCount, 3); +}); + +test("insertConversationTurnNodes + getConversationTurnIndex round-trip", () => { + const row = createAgenticConversation({ apiKeyId: "key-nodes", fingerprintHash: "fp-nodes" }); + + insertConversationTurnNodes(row.id, "corr-1", [ + { id: "node-a", parentId: null, role: "user", contentHash: "hash-a" }, + { id: "node-b", parentId: "node-a", role: "assistant", contentHash: "hash-b" }, + ]); + + const index = getConversationTurnIndex(row.id); + assert.equal(index.nodeIds.size, 2); + assert.ok(index.nodeIds.has("node-a")); + assert.ok(index.nodeIds.has("node-b")); + assert.deepEqual(index.byContentHash.get("hash-a"), ["node-a"]); + assert.deepEqual(index.byContentHash.get("hash-b"), ["node-b"]); + + // A different conversation's nodes must never leak into this index. + const other = createAgenticConversation({ + apiKeyId: "key-nodes-2", + fingerprintHash: "fp-nodes-2", + }); + insertConversationTurnNodes(other.id, "corr-2", [ + { id: "node-c", parentId: null, role: "user", contentHash: "hash-c" }, + ]); + const reReadIndex = getConversationTurnIndex(row.id); + assert.equal(reReadIndex.nodeIds.size, 2); + assert.equal(reReadIndex.byContentHash.has("hash-c"), false); +}); + +test("getConversationTurnIndex groups multiple node ids under the same content hash (duplicate turn text at different tree positions)", () => { + const row = createAgenticConversation({ apiKeyId: "key-dup-content", fingerprintHash: "fp-dup" }); + + insertConversationTurnNodes(row.id, "corr-1", [ + { id: "node-1", parentId: null, role: "user", contentHash: "hash-ok" }, + { id: "node-2", parentId: "node-1", role: "assistant", contentHash: "hash-reply" }, + // Same content ("ok") recurs later in the same tree, at a different node. + { id: "node-3", parentId: "node-2", role: "user", contentHash: "hash-ok" }, + ]); + + const index = getConversationTurnIndex(row.id); + const matches = index.byContentHash.get("hash-ok"); + assert.equal(matches?.length, 2); + assert.deepEqual([...matches!].sort(), ["node-1", "node-3"]); +}); + +test("insertConversationTurnNodes is idempotent for already-existing node ids (INSERT OR IGNORE)", () => { + const row = createAgenticConversation({ apiKeyId: "key-idem", fingerprintHash: "fp-idem" }); + + insertConversationTurnNodes(row.id, "corr-1", [ + { id: "node-dup", parentId: null, role: "user", contentHash: "hash-dup" }, + ]); + // Re-insert the same id — must not throw, must not duplicate. + insertConversationTurnNodes(row.id, "corr-2", [ + { id: "node-dup", parentId: null, role: "user", contentHash: "hash-dup" }, + ]); + + const tree = getConversationTurnPage(row.id, { limit: 500 }).nodes; + assert.equal(tree.length, 1); +}); + +test("getConversationTurnPage returns the full chain with parent/child structure and content hash", () => { + const row = createAgenticConversation({ apiKeyId: "key-tree", fingerprintHash: "fp-tree" }); + + insertConversationTurnNodes(row.id, "corr-tree", [ + { id: "root-turn", parentId: null, role: "user", contentHash: "hash-hello" }, + { id: "child-turn", parentId: "root-turn", role: "assistant", contentHash: "hash-hi" }, + ]); + // A sibling branch off the same parent. + insertConversationTurnNodes(row.id, "corr-tree-2", [ + { id: "sibling-turn", parentId: "root-turn", role: "assistant", contentHash: "hash-hey" }, + ]); + + const tree = getConversationTurnPage(row.id, { limit: 500 }).nodes; + assert.equal(tree.length, 3); + + const root = tree.find((n) => n.id === "root-turn"); + const children = tree.filter((n) => n.parentId === "root-turn"); + assert.equal(root?.parentId, null); + assert.equal(root?.contentHash, "hash-hello"); + assert.equal(children.length, 2); + assert.deepEqual(children.map((c) => c.id).sort(), ["child-turn", "sibling-turn"]); +}); + +test("getConversationTurnPage: initial load returns only the last `limit` turns, oldest-first, with hasMore", () => { + const row = createAgenticConversation({ apiKeyId: "key-page", fingerprintHash: "fp-page" }); + const nodes = Array.from({ length: 25 }, (_, i) => ({ + id: `n${i}`, + parentId: i === 0 ? null : `n${i - 1}`, + role: i % 2 === 0 ? "user" : "assistant", + contentHash: `hash-${i}`, + })); + insertConversationTurnNodes(row.id, "corr-page", nodes); + + const page = getConversationTurnPage(row.id, { limit: 20 }); + assert.equal(page.nodes.length, 20); + assert.equal(page.hasMore, true); + // Oldest-first within the page, and it's the LAST 20 (n5..n24). + assert.equal(page.nodes[0].id, "n5"); + assert.equal(page.nodes[19].id, "n24"); +}); + +test("getConversationTurnPage: beforeSeq loads the previous page (older turns), with correct hasMore", () => { + const row = createAgenticConversation({ apiKeyId: "key-page-2", fingerprintHash: "fp-page-2" }); + const nodes = Array.from({ length: 25 }, (_, i) => ({ + id: `m${i}`, + parentId: i === 0 ? null : `m${i - 1}`, + role: "user", + contentHash: `hash-m${i}`, + })); + insertConversationTurnNodes(row.id, "corr-page-2", nodes); + + const firstPage = getConversationTurnPage(row.id, { limit: 20 }); + const oldestSeqInFirstPage = firstPage.nodes[0].seq; + + const olderPage = getConversationTurnPage(row.id, { limit: 20, beforeSeq: oldestSeqInFirstPage }); + assert.equal(olderPage.nodes.length, 5, "only 5 turns (0-4) exist before the first page"); + assert.equal(olderPage.hasMore, false); + assert.equal(olderPage.nodes[0].id, "m0"); + assert.equal(olderPage.nodes[4].id, "m4"); +}); + +test("getConversationTurnPage: afterSeq returns only turns newer than the cursor (for polling), uncapped", () => { + const row = createAgenticConversation({ apiKeyId: "key-page-3", fingerprintHash: "fp-page-3" }); + insertConversationTurnNodes(row.id, "corr-page-3", [ + { id: "p0", parentId: null, role: "user", contentHash: "h0" }, + { id: "p1", parentId: "p0", role: "assistant", contentHash: "h1" }, + ]); + const firstPage = getConversationTurnPage(row.id, { limit: 20 }); + const newestSeq = firstPage.nodes[firstPage.nodes.length - 1].seq; + + // Nothing new yet. + assert.equal(getConversationTurnPage(row.id, { afterSeq: newestSeq }).nodes.length, 0); + + // A new turn arrives (e.g. a later request continuing this conversation). + insertConversationTurnNodes(row.id, "corr-page-3b", [ + { id: "p2", parentId: "p1", role: "user", contentHash: "h2" }, + ]); + const polled = getConversationTurnPage(row.id, { afterSeq: newestSeq }); + assert.equal(polled.nodes.length, 1); + assert.equal(polled.nodes[0].id, "p2"); + assert.equal(polled.hasMore, false); +}); + +test("touchOrCreateExternalConversation creates then increments turn_count on repeat calls", () => { + const id = "ext-conv-test-id"; + touchOrCreateExternalConversation(id, { apiKeyId: "key-d" }); + + const db = getDbInstance(); + const afterCreate = db + .prepare("SELECT turn_count FROM agentic_conversations WHERE id = ?") + .get(id) as { turn_count: number }; + assert.equal(afterCreate.turn_count, 1); + + touchOrCreateExternalConversation(id, { apiKeyId: "key-d" }); + const afterTouch = db + .prepare("SELECT turn_count FROM agentic_conversations WHERE id = ?") + .get(id) as { turn_count: number }; + assert.equal(afterTouch.turn_count, 2); +}); + +test("listMultiTurnConversations only returns conversations with >= 2 actual turn nodes, joined to their latest call_logs row", () => { + const db = getDbInstance(); + + createAgenticConversation({ + id: "conv-single-turn", + apiKeyId: null, + fingerprintHash: "fp-single", + }); + insertConversationTurnNodes("conv-single-turn", "corr-single", [ + { id: "single-node-1", parentId: null, role: "user", contentHash: "hash-single-1" }, + ]); + + const multi = createAgenticConversation({ + id: "conv-multi-turn", + apiKeyId: null, + fingerprintHash: "fp-multi", + }); + // turn_count deliberately left at its default of 1 here: it tracks + // requests-touched, not node count, and a freshly-minted conversation can + // already carry many turn nodes from a single insert (see the doc comment + // on listMultiTurnConversations) — the filter must key off actual node + // count, not turn_count, for this conversation to be listed at all. + insertConversationTurnNodes(multi.id, "corr-multi", [ + { id: "multi-node-1", parentId: null, role: "user", contentHash: "hash-multi-1" }, + { + id: "multi-node-2", + parentId: "multi-node-1", + role: "assistant", + contentHash: "hash-multi-2", + }, + ]); + + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', 'opencode-zen', ?)` + ).run("multi-turn-1", "2026-03-01T00:00:00.000Z", "conv-multi-turn"); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, provider, session_tag) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'gemma-4', 'gemini', ?)` + ).run("multi-turn-2", "2026-03-01T00:01:00.000Z", "conv-multi-turn"); + + const { rows, total } = listMultiTurnConversations(); + const ids = rows.map((r) => r.id); + assert.ok(ids.includes("conv-multi-turn")); + assert.ok(!ids.includes("conv-single-turn")); + assert.ok(total >= 1); + + const found = rows.find((r) => r.id === "conv-multi-turn"); + assert.equal(found?.lastCallLogId, "multi-turn-2"); + assert.equal(found?.lastModel, "gemma-4"); + assert.equal(found?.lastProvider, "gemini"); +}); + +test("resolveCallLogIdsByCorrelationIds bulk-resolves correlation_id to call_logs.id", () => { + const db = getDbInstance(); + + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, correlation_id) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("call-corr-1", "2026-04-01T00:00:00.000Z", "corr-a"); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, correlation_id) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?)` + ).run("call-corr-2", "2026-04-01T00:01:00.000Z", "corr-b"); + + const resolved = resolveCallLogIdsByCorrelationIds(["corr-a", "corr-b", "corr-missing"]); + assert.equal(resolved.get("corr-a"), "call-corr-1"); + assert.equal(resolved.get("corr-b"), "call-corr-2"); + assert.equal(resolved.has("corr-missing"), false); +}); + +test("resolveCallLogIdsByCorrelationIds returns an empty map for an empty/all-falsy input", () => { + assert.equal(resolveCallLogIdsByCorrelationIds([]).size, 0); + assert.equal(resolveCallLogIdsByCorrelationIds(["", ""]).size, 0); +}); diff --git a/tests/unit/agentrouter-quota-dashboard-rendering.test.ts b/tests/unit/agentrouter-quota-dashboard-rendering.test.ts new file mode 100644 index 0000000000..4a410be9ad --- /dev/null +++ b/tests/unit/agentrouter-quota-dashboard-rendering.test.ts @@ -0,0 +1,117 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getAgentrouterUsage } from "../../open-sse/services/usage/agentrouter.ts"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function mockAgentrouterFetch(rawQuota: number) { + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: { quota: rawQuota } }), { + status: 200, + headers: { "content-type": "application/json" }, + })) as typeof fetch; +} + +/** + * #10078 follow-up — the original fix wired AgentRouter's balance into + * getUsageForProvider()/USAGE_SUPPORTED_PROVIDERS (visibility + data path), but the + * Dashboard Quota UI *renderer* (QuotaCardBody / QuotaCardExpanded under + * src/app/(dashboard)/dashboard/usage/components/ProviderLimits/) only formats a + * quota row as a dollar amount ("$X.XX") when the row carries `isCredits: true` + + * `currency` + `creditCount` — fields the generic quota-parsing path + * (parseGeneric -> normalizeQuotaEntry in quotaParsing.ts) never sets, and never + * copies `currency` through at all. Because "agentrouter" wasn't special-cased in + * parseProviderQuotas(), a configured balance rendered as a bare "100% left" + * percentage (not USD), and the real dollar figure (`dollarBalance`) was only + * exposed as a top-level `remainingUsd`/`availableUsd`/`balance` sibling field that + * parseQuotaData() never reads (it only walks `data.quotas`). + * + * This test drives the real producer (getAgentrouterUsage) through the real + * Dashboard adapter (parseQuotaData) end-to-end — the same path the Provider + * Limits UI takes — and asserts the row the renderer actually consumes + * (`isCredits`, `currency`, `creditCount`) instead of just the wire-shape fields + * asserted by tests/unit/agentrouter-quota-visibility.test.ts. + */ +test("#10078: a configured AgentRouter balance renders as a USD credits row in the Dashboard Quota UI", async () => { + const connectionId = `agentrouter-dash-configured-${Date.now()}`; + mockAgentrouterFetch(250_000); // 250_000 / 500_000 QUOTA_PER_UNIT = $0.50 + + const usage = await getAgentrouterUsage(connectionId, { + provider: "agentrouter", + providerSpecificData: { consoleApiKey: "system-access-token", newApiUserId: "42" }, + }); + + const rows = parseQuotaData("agentrouter", usage) as Array<{ + isCredits?: boolean; + currency?: string; + creditCount?: number; + remainingPercentage?: number; + }>; + + assert.equal(rows.length, 1, `expected exactly one quota row, got: ${JSON.stringify(rows)}`); + const [row] = rows; + + // These are exactly the fields QuotaCardBody.tsx / QuotaCardExpanded.tsx branch on + // to render a dollar-formatted amount ("$0.50") instead of a bare percentage. + assert.equal(row.isCredits, true, "renderer only formats USD when isCredits is true"); + assert.equal(row.currency, "USD", "renderer looks up CURRENCY_SYMBOLS[q.currency]"); + assert.equal(row.creditCount, 0.5, "renderer displays q.creditCount as the dollar amount"); + assert.equal(row.remainingPercentage, 100, "a funded wallet must not read as exhausted"); +}); + +test("#10078: an exhausted AgentRouter balance renders as exactly $0, not negative or NaN", async () => { + const connectionId = `agentrouter-dash-exhausted-${Date.now()}`; + mockAgentrouterFetch(0); + + const usage = await getAgentrouterUsage(connectionId, { + provider: "agentrouter", + providerSpecificData: { consoleApiKey: "system-access-token", newApiUserId: "42" }, + }); + + const rows = parseQuotaData("agentrouter", usage) as Array<{ + isCredits?: boolean; + currency?: string; + creditCount?: number; + remainingPercentage?: number; + }>; + + assert.equal(rows.length, 1); + const [row] = rows; + + assert.equal(row.isCredits, true); + assert.equal(row.currency, "USD"); + assert.equal(row.creditCount, 0, "exhausted balance must render as exactly zero"); + assert.equal(Number.isFinite(row.creditCount), true, "must never render NaN"); + assert.ok((row.creditCount ?? -1) >= 0, "must never render negative"); + assert.equal(row.remainingPercentage, 0, "exhausted wallet must read as 0% remaining (critical color)"); +}); + +test("#10078: parseQuotaData never drops a raw negative/garbage remaining as -$X — clamps to 0", () => { + // Defends the Math.max(0, ...) clamp in both getAgentrouterUsage() and + // parseAgentrouterQuota() against a malformed/negative upstream `remaining`. + const data = { + plan: "AgentRouter", + quotas: { + balance: { + used: 0, + total: 0, + remaining: -5, + remainingPercentage: 0, + resetAt: null, + unlimited: true, + currency: "USD", + displayName: "Wallet Balance (USD)", + }, + }, + }; + + const rows = parseQuotaData("agentrouter", data) as Array<{ creditCount?: number }>; + assert.equal(rows.length, 1); + assert.equal(rows[0].creditCount, 0); +}); diff --git a/tests/unit/agentrouter-quota-visibility.test.ts b/tests/unit/agentrouter-quota-visibility.test.ts new file mode 100644 index 0000000000..a6a29b0d0e --- /dev/null +++ b/tests/unit/agentrouter-quota-visibility.test.ts @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.ts"; +import { supportsProviderQuota } from "../../src/shared/utils/providerQuotaVisibility.ts"; +import { + USAGE_FETCHER_PROVIDERS, + getUsageForProvider, +} from "../../open-sse/services/usage.ts"; +import { + getAgentrouterUsage, +} from "../../open-sse/services/usage/agentrouter.ts"; +import { + invalidateAgentrouterQuotaCache, + type AgentrouterQuota, +} from "../../open-sse/services/agentrouterQuotaFetcher.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** + * #10078 — AgentRouter quota was missing from the dashboard: + * - USAGE_SUPPORTED_PROVIDERS (visibility gate) omitted "agentrouter", and + * - USAGE_FETCHER_PROVIDERS + getUsageForProvider (the provider-limits data + * path) had no "agentrouter" case, so /api/usage/provider-limits fell back + * to the generic "Usage API not implemented" message. + * These three assertions are the permanent regression guard (RED before the + * fix, GREEN after). + */ +test("#10078: agentrouter is present in USAGE_SUPPORTED_PROVIDERS", () => { + assert.equal( + USAGE_SUPPORTED_PROVIDERS.includes("agentrouter" as (typeof USAGE_SUPPORTED_PROVIDERS)[number]), + true + ); +}); + +test("#10078: supportsProviderQuota('agentrouter') is true", () => { + assert.equal(supportsProviderQuota("agentrouter"), true); +}); + +test("#10078: agentrouter is present in USAGE_FETCHER_PROVIDERS", () => { + assert.equal( + USAGE_FETCHER_PROVIDERS.includes("agentrouter" as (typeof USAGE_FETCHER_PROVIDERS)[number]), + true + ); +}); + +test("#10078: getUsageForProvider shapes the AgentRouter balance into a USD quota", async () => { + const connectionId = `agentrouter-vis-${Date.now()}`; + globalThis.fetch = (async () => { + return new Response(JSON.stringify({ data: { quota: 250_000 } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + const usage = (await getUsageForProvider({ + id: connectionId, + provider: "agentrouter", + providerSpecificData: { consoleApiKey: "system-access-token", newApiUserId: "42" }, + })) as { + plan?: string; + quotas?: Record; + remainingUsd?: number; + }; + + assert.equal(usage.plan, "AgentRouter"); + assert.ok(usage.quotas); + const balance = usage.quotas.balance; + assert.ok(balance, "expected a `balance` quota entry"); + assert.equal(balance.displayName, "Wallet Balance (USD)"); + assert.equal(usage.remainingUsd, 0.5); +}); + +test("#10078: getAgentrouterUsage returns a graceful message when console credentials are missing", async () => { + const usage = (await getAgentrouterUsage(`missing-${Date.now()}`, { + provider: "agentrouter", + })) as { message?: string; quotas?: unknown }; + + assert.equal(typeof usage.message, "string"); + assert.ok(/not available/i.test(usage.message || "")); + assert.equal(usage.quotas, undefined); +}); \ No newline at end of file diff --git a/tests/unit/aihorde-image-catalog.test.ts b/tests/unit/aihorde-image-catalog.test.ts new file mode 100644 index 0000000000..d527f65ed2 --- /dev/null +++ b/tests/unit/aihorde-image-catalog.test.ts @@ -0,0 +1,104 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + HordeImageCatalog, + parseHordeImageModels, + resetAiHordeImageCatalog, + getCachedAiHordeImageCatalogEntries, + aiHordeImageCatalog, +} from "../../open-sse/services/aihordeImageCatalog.ts"; +import { + parseImageModel, + getImageProvider, + getAllImageModels, +} from "../../open-sse/config/imageRegistry.ts"; + +const HORDE_MODELS = [ + { + name: "FLUX.1-schnell", + count: 6, + queued: 2, + eta: 12, + performance: 18.5, + type: "image", + }, + { + name: "AlbedoBase XL (SDXL)", + count: 1, + queued: 0, + eta: 4, + type: "image", + }, + { name: "DeadModel", count: 0, type: "image" }, + { name: "koboldcpp/Gemma", count: 3, type: "text" }, +]; + +test.afterEach(() => { + resetAiHordeImageCatalog(); +}); + +test("parseHordeImageModels drops zero-worker and text models", () => { + const models = parseHordeImageModels(HORDE_MODELS); + assert.deepEqual( + models.map((model) => model.name), + ["AlbedoBase XL (SDXL)", "FLUX.1-schnell"] + ); + assert.equal(models[1].count, 6); +}); + +test("parseHordeImageModels rejects a non-array payload", () => { + assert.throws(() => parseHordeImageModels({ name: "nope" }), /JSON array/); +}); + +test("refresh keeps the last good snapshot when Horde is down", async () => { + let calls = 0; + const catalog = new HordeImageCatalog({ + pollMs: 5_000, + fetchImpl: async () => { + calls += 1; + if (calls === 1) { + return new Response(JSON.stringify(HORDE_MODELS), { status: 200 }); + } + return new Response(JSON.stringify({ message: "maintenance" }), { status: 503 }); + }, + }); + + await catalog.refresh(); + assert.equal(catalog.isServed("FLUX.1-schnell"), true); + assert.equal(catalog.snapshot.lastError, null); + + await catalog.refresh(); + assert.equal(catalog.isServed("FLUX.1-schnell"), true); + assert.equal(catalog.stale, true); + assert.ok(catalog.snapshot.lastError); + assert.equal( + catalog.listModels().some((model) => model.name === "DeadModel"), + false + ); +}); + +test("live catalog entries use exact Horde names and the aihorde prefix", async () => { + aiHordeImageCatalog.setFetch( + async () => new Response(JSON.stringify(HORDE_MODELS), { status: 200 }) + ); + await aiHordeImageCatalog.refresh(); + const ids = getCachedAiHordeImageCatalogEntries().map((model) => model.id); + assert.deepEqual(ids, ["aihorde/AlbedoBase XL (SDXL)", "aihorde/FLUX.1-schnell"]); + const listed = getAllImageModels().map((model) => model.id); + assert.ok(listed.includes("aihorde/FLUX.1-schnell")); + assert.equal(listed.includes("aihorde/DeadModel"), false); +}); + +test("parseImageModel accepts aihorde/ and horde/ prefixes for live names", () => { + assert.deepEqual(parseImageModel("aihorde/Flux.1-Schnell fp8 (Compact)"), { + provider: "aihorde", + model: "Flux.1-Schnell fp8 (Compact)", + }); + assert.deepEqual(parseImageModel("horde/AlbedoBase XL (SDXL)"), { + provider: "aihorde", + model: "AlbedoBase XL (SDXL)", + }); + assert.equal(getImageProvider("aihorde")?.format, "aihorde"); + assert.equal(getImageProvider("aihorde")?.alias, "horde"); +}); diff --git a/tests/unit/aihorde-image-generation.test.ts b/tests/unit/aihorde-image-generation.test.ts new file mode 100644 index 0000000000..bc0e20de58 --- /dev/null +++ b/tests/unit/aihorde-image-generation.test.ts @@ -0,0 +1,271 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-aihorde-image-")); + +import { + capHordeN, + mapHordeGenerateRequest, + parseHordeSize, + stripHordeModelPrefix, +} from "../../open-sse/handlers/imageGeneration/providers/aihordeMapRequest.ts"; +import { handleAiHordeImageGeneration } from "../../open-sse/handlers/imageGeneration/providers/aihorde.ts"; +import { handleImageGeneration } from "../../open-sse/handlers/imageGeneration.ts"; +import { aiHordeImageCatalog } from "../../open-sse/services/aihordeImageCatalog.ts"; + +test("map helpers snap size, cap n, and strip prefixes", () => { + assert.equal(stripHordeModelPrefix("aihorde/FLUX.1-schnell"), "FLUX.1-schnell"); + assert.equal(stripHordeModelPrefix("horde/AlbedoBase XL (SDXL)"), "AlbedoBase XL (SDXL)"); + assert.deepEqual(parseHordeSize("1000x1000"), { width: 1024, height: 1024 }); + assert.equal(capHordeN(9), 4); +}); + +test("mapHordeGenerateRequest builds a native Horde payload", () => { + const payload = mapHordeGenerateRequest({ + model: "aihorde/FLUX.1-schnell", + prompt: "a red fox in snow", + n: 2, + size: "1024x768", + }); + assert.equal(payload.prompt, "a red fox in snow"); + assert.deepEqual(payload.models, ["FLUX.1-schnell"]); + assert.equal((payload.params as { n: number }).n, 2); + assert.equal((payload.params as { width: number }).width, 1024); + assert.equal((payload.params as { height: number }).height, 768); + assert.equal(payload.r2, true); +}); + +test("handleAiHordeImageGeneration rejects a model with zero workers", async () => { + aiHordeImageCatalog.replace([ + { name: "AlbedoBase XL (SDXL)", count: 1, queued: 0, eta: 1, performance: 1, jobs: 0 }, + ]); + const result = await handleAiHordeImageGeneration({ + model: "FLUX.1-schnell", + provider: "aihorde", + body: { model: "aihorde/FLUX.1-schnell", prompt: "fox" }, + credentials: { apiKey: "horde-key" }, + }); + assert.equal(result.success, false); + assert.equal(result.status, 400); + assert.match(String(result.error), /No Horde workers/); +}); + +test("exceeding the deadline issues a DELETE cancel to Horde, not just a local timeout", async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ method: string; url: string }> = []; + + aiHordeImageCatalog.replace([ + { name: "FLUX.1-schnell", count: 3, queued: 0, eta: 1, performance: 1, jobs: 0 }, + ]); + + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method || "GET").toUpperCase(); + calls.push({ method, url }); + + if (url.includes("/v2/generate/async")) { + return new Response(JSON.stringify({ id: "job-timeout" }), { status: 202 }); + } + if (method === "DELETE" && url.includes("/v2/generate/status/")) { + return new Response(JSON.stringify({ id: "job-timeout" }), { status: 200 }); + } + if (url.includes("/v2/generate/check/")) { + // Never reports done — the generation deadline must be what ends the loop. + return new Response(JSON.stringify({ done: false, is_possible: true, faulted: false }), { + status: 200, + }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + try { + const result = await handleAiHordeImageGeneration({ + model: "FLUX.1-schnell", + provider: "aihorde", + body: { model: "aihorde/FLUX.1-schnell", prompt: "a red fox in snow" }, + credentials: { apiKey: "horde-key" }, + // Small enough that the poll loop's 1s interval crosses the deadline + // on its first iteration, but non-zero so submit itself isn't rejected. + timeoutMs: 50, + }); + + assert.equal(result.success, false); + assert.equal(result.status, 504); + assert.match(String(result.error), /timed out/); + + const cancelCall = calls.find( + (call) => call.method === "DELETE" && call.url.includes("/v2/generate/status/job-timeout") + ); + assert.ok(cancelCall, "expected a DELETE cancel call to Horde's status endpoint"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a private-host R2 image URL is blocked by the SSRF guard, not fetched", async () => { + const originalFetch = globalThis.fetch; + let downloadAttempted = false; + + aiHordeImageCatalog.replace([ + { name: "FLUX.1-schnell", count: 3, queued: 0, eta: 1, performance: 1, jobs: 0 }, + ]); + + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method || "GET").toUpperCase(); + + if (url.includes("/v2/generate/async")) { + return new Response(JSON.stringify({ id: "job-ssrf" }), { status: 202 }); + } + if (method === "DELETE") { + return new Response("{}", { status: 200 }); + } + if (url.includes("/v2/generate/check/")) { + return new Response(JSON.stringify({ done: true, is_possible: true, faulted: false }), { + status: 200, + }); + } + if (url.includes("/v2/generate/status/")) { + return new Response( + JSON.stringify({ generations: [{ img: "http://127.0.0.1:9999/internal-secret.png" }] }), + { status: 200 } + ); + } + // A real HTTP fetch reaching the private host means the guard failed to + // block it before the network call. + downloadAttempted = true; + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + try { + const result = await handleAiHordeImageGeneration({ + model: "FLUX.1-schnell", + provider: "aihorde", + body: { model: "aihorde/FLUX.1-schnell", prompt: "a red fox in snow" }, + credentials: { apiKey: "horde-key" }, + }); + + assert.equal(result.success, false); + assert.equal(downloadAttempted, false, "the private-host URL must never reach fetch()"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an oversized R2 image download is rejected instead of buffered whole", async () => { + const originalFetch = globalThis.fetch; + + aiHordeImageCatalog.replace([ + { name: "FLUX.1-schnell", count: 3, queued: 0, eta: 1, performance: 1, jobs: 0 }, + ]); + + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const method = (init?.method || "GET").toUpperCase(); + + if (url.includes("/v2/generate/async")) { + return new Response(JSON.stringify({ id: "job-oversized" }), { status: 202 }); + } + if (method === "DELETE") { + return new Response("{}", { status: 200 }); + } + if (url.includes("/v2/generate/check/")) { + return new Response(JSON.stringify({ done: true, is_possible: true, faulted: false }), { + status: 200, + }); + } + // A raw public IP literal (not a hostname) skips the SSRF guard's real DNS + // lookup entirely — this test only cares about the byte-cap, not the host + // resolution path (already covered by the private-host test above), and + // the sandboxed test env has no DNS egress. + if (url.includes("/v2/generate/status/")) { + return new Response( + JSON.stringify({ generations: [{ img: "https://93.184.216.34/huge.png" }] }), + { status: 200 } + ); + } + if (url.includes("93.184.216.34")) { + return new Response("x", { + status: 200, + headers: { "content-length": String(30 * 1024 * 1024) }, + }); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + try { + const result = await handleAiHordeImageGeneration({ + model: "FLUX.1-schnell", + provider: "aihorde", + body: { model: "aihorde/FLUX.1-schnell", prompt: "a red fox in snow" }, + credentials: { apiKey: "horde-key" }, + }); + + assert.equal(result.success, false); + assert.match(String(result.error), /exceeds|byte limit|too large/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("handleImageGeneration dispatches aihorde and sends the apikey header", async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; headers: Record; body?: unknown }> = []; + + aiHordeImageCatalog.replace([ + { name: "FLUX.1-schnell", count: 3, queued: 0, eta: 1, performance: 1, jobs: 0 }, + ]); + + globalThis.fetch = (async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const headers = Object.fromEntries(new Headers(init?.headers).entries()); + let body: unknown; + if (typeof init?.body === "string") { + try { + body = JSON.parse(init.body); + } catch { + body = init.body; + } + } + calls.push({ url, headers, body }); + + if (url.includes("/v2/generate/async")) { + return new Response(JSON.stringify({ id: "job-1" }), { status: 202 }); + } + if (url.includes("/v2/generate/check/")) { + return new Response(JSON.stringify({ done: true, is_possible: true, faulted: false }), { + status: 200, + }); + } + if (url.includes("/v2/generate/status/")) { + return new Response( + JSON.stringify({ generations: [{ img: Buffer.from("png-bytes").toString("base64") }] }), + { status: 200 } + ); + } + return new Response("unexpected", { status: 500 }); + }) as typeof fetch; + + try { + const result = await handleImageGeneration({ + body: { model: "aihorde/FLUX.1-schnell", prompt: "a red fox in snow", size: "1024x1024" }, + credentials: { apiKey: "horde-registered-key" }, + log: null, + }); + assert.equal(result.success, true); + const submit = calls.find((call) => call.url.includes("/v2/generate/async")); + assert.ok(submit); + assert.equal(submit.headers.apikey, "horde-registered-key"); + assert.ok(submit.headers["client-agent"]); + assert.deepEqual((submit.body as { models: string[] }).models, ["FLUX.1-schnell"]); + assert.equal( + (result as { data: { data: Array<{ b64_json: string }> } }).data.data[0].b64_json, + Buffer.from("png-bytes").toString("base64") + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/aihorde-key-validation.test.ts b/tests/unit/aihorde-key-validation.test.ts new file mode 100644 index 0000000000..0bcaa320f1 --- /dev/null +++ b/tests/unit/aihorde-key-validation.test.ts @@ -0,0 +1,71 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { readFileSync } from "node:fs"; +import { validateAiHordeProvider } from "../../src/lib/providers/validation/aihorde.ts"; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +test("empty Horde key is valid because the provider is optional", async () => { + const result = await validateAiHordeProvider({ + apiKey: " ", + fetchImpl: async () => { + throw new Error("find_user must not run when no key is pasted"); + }, + }); + assert.equal(result.valid, true); + assert.equal(result.method, "aihorde_anonymous"); +}); + +test("junk Horde key is rejected by find_user 404", async () => { + const result = await validateAiHordeProvider({ + apiKey: "junk-key-not-real", + fetchImpl: async () => + jsonResponse(404, { + message: "User with api_key 'junk-key-not-real' not found.", + rc: "UserNotFound", + }), + }); + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("missing Horde key header is rejected by find_user 401", async () => { + const result = await validateAiHordeProvider({ + apiKey: "not-a-real-key", + fetchImpl: async () => + jsonResponse(401, { message: "No user matching sent API Key.", rc: "InvalidAPIKey" }), + }); + assert.equal(result.valid, false); + assert.equal(result.error, "Invalid API key"); +}); + +test("registered Horde key is accepted when find_user returns a username", async () => { + let sentKey = ""; + let sentUrl = ""; + const result = await validateAiHordeProvider({ + apiKey: "horde-registered-key-123", + fetchImpl: async (url, init) => { + sentUrl = String(url); + sentKey = new Headers(init?.headers).get("apikey") || ""; + return jsonResponse(200, { username: "tester#1234", kudos: 100 }); + }, + }); + assert.equal(result.valid, true); + assert.equal(result.method, "aihorde_find_user"); + assert.equal(sentKey, "horde-registered-key-123"); + assert.match(sentUrl, /\/v2\/find_user$/); +}); + +test("validation.ts registers the Horde find_user specialty validator", () => { + const src = readFileSync( + new URL("../../src/lib/providers/validation.ts", import.meta.url), + "utf8" + ); + assert.match(src, /aihorde:\s*validateAiHordeProvider/); +}); diff --git a/tests/unit/aihorde-optional-api-key.test.ts b/tests/unit/aihorde-optional-api-key.test.ts new file mode 100644 index 0000000000..9a69669860 --- /dev/null +++ b/tests/unit/aihorde-optional-api-key.test.ts @@ -0,0 +1,140 @@ +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-aihorde-key-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "aihorde-optional-key-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const { getProviderCredentials } = await import("../../src/sse/services/auth.ts"); +const { supportsApiKeyOnFreeProvider, providerAllowsOptionalApiKey } = + await import("../../src/shared/constants/providers.ts"); +const { getCredentialRequirement } = + await import("../../src/shared/utils/providerCredentialRequirement.ts"); +const { DefaultExecutor } = await import("../../open-sse/executors/default.ts"); +const { isManagedProviderConnectionId } = await import("../../src/lib/providers/catalog.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("aihorde treats a registered key as optional, not required", () => { + assert.equal(providerAllowsOptionalApiKey("aihorde"), true); + assert.equal(supportsApiKeyOnFreeProvider("aihorde"), true); + assert.equal(getCredentialRequirement("aihorde"), "optional"); + assert.equal(isManagedProviderConnectionId("aihorde"), true); +}); + +test("aihorde without a stored key still uses the synthetic no-auth path", async () => { + const creds = await getProviderCredentials("aihorde"); + assert.ok(creds); + assert.equal((creds as { connectionId?: string }).connectionId, "noauth"); + assert.equal((creds as { apiKey?: unknown }).apiKey, null); +}); + +let registeredKeyConnectionId: string | undefined; + +test("aihorde prefers a stored API key over the anonymous fallback", async () => { + const created = await providersDb.createProviderConnection({ + provider: "aihorde", + authType: "apikey", + name: "Horde kudos key", + apiKey: "horde-registered-key-123", + }); + assert.ok(created?.id); + registeredKeyConnectionId = created.id; + + const creds = await getProviderCredentials("aihorde"); + assert.ok(creds); + assert.equal((creds as { apiKey?: string }).apiKey, "horde-registered-key-123"); + assert.equal((creds as { connectionId?: string }).connectionId, created.id); +}); + +test("aihorde falls back to anonymous when the only stored key is rate-limited", async () => { + // Deactivate the healthy connection from the prior test so it cannot mask + // the fallback behavior being exercised here. + assert.ok(registeredKeyConnectionId); + await providersDb.updateProviderConnection(registeredKeyConnectionId, { isActive: false }); + + const created = await providersDb.createProviderConnection({ + provider: "aihorde", + authType: "apikey", + name: "Horde cooling-down key", + apiKey: "horde-cooling-down-key", + }); + assert.ok(created?.id); + await providersDb.updateProviderConnection(created.id, { + rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), + testStatus: "unavailable", + }); + + const creds = await getProviderCredentials("aihorde"); + assert.ok(creds); + assert.equal((creds as { connectionId?: string }).connectionId, "noauth"); + assert.equal((creds as { apiKey?: unknown }).apiKey, null); +}); + +test("aihorde falls back to anonymous when the only stored key is terminally banned", async () => { + const created = await providersDb.createProviderConnection({ + provider: "aihorde", + authType: "apikey", + name: "Horde banned key", + apiKey: "horde-banned-key", + }); + assert.ok(created?.id); + await providersDb.updateProviderConnection(created.id, { testStatus: "banned" }); + + const creds = await getProviderCredentials("aihorde"); + assert.ok(creds); + assert.equal((creds as { connectionId?: string }).connectionId, "noauth"); + assert.equal((creds as { apiKey?: unknown }).apiKey, null); +}); + +test("aihorde rotates past an unhealthy stored key to the next healthy one", async () => { + const unhealthy = await providersDb.createProviderConnection({ + provider: "aihorde", + authType: "apikey", + name: "Horde unhealthy key", + apiKey: "horde-unhealthy-key", + priority: 1, + }); + assert.ok(unhealthy?.id); + await providersDb.updateProviderConnection(unhealthy.id, { + rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), + testStatus: "unavailable", + }); + + const healthy = await providersDb.createProviderConnection({ + provider: "aihorde", + authType: "apikey", + name: "Horde healthy key", + apiKey: "horde-healthy-key", + priority: 2, + }); + assert.ok(healthy?.id); + + const creds = await getProviderCredentials("aihorde"); + assert.ok(creds); + assert.equal((creds as { apiKey?: string }).apiKey, "horde-healthy-key"); + assert.equal((creds as { connectionId?: string }).connectionId, healthy.id); +}); + +test("DefaultExecutor uses a stored Horde key for chat, else the anonymous key", () => { + const executor = new DefaultExecutor("aihorde"); + const withKey = executor.buildHeaders( + { apiKey: "horde-registered-key-123", accessToken: null } as never, + true + ) as Record; + assert.equal(withKey.Authorization, "Bearer horde-registered-key-123"); + + const anonymous = executor.buildHeaders( + { apiKey: null, accessToken: null } as never, + true + ) as Record; + assert.equal(anonymous.Authorization, "Bearer 0000000000"); +}); diff --git a/tests/unit/alibaba-provider-regions.test.ts b/tests/unit/alibaba-provider-regions.test.ts index a9e8a65094..872b11de5e 100644 --- a/tests/unit/alibaba-provider-regions.test.ts +++ b/tests/unit/alibaba-provider-regions.test.ts @@ -24,8 +24,8 @@ test("Alibaba-family endpoint matrix keeps product and region boundaries distinc "china-beijing": "https://dashscope.aliyuncs.com/compatible-mode/v1", }, "bailian-coding-plan": { - "global-sg": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", - "china-beijing": "https://coding.dashscope.aliyuncs.com/apps/anthropic/v1", + "global-sg": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", + "china-beijing": "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic/v1", }, "qwen-cloud": { "global-sg": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", @@ -92,7 +92,7 @@ test("DefaultExecutor applies the regional endpoint to normal requests", () => { codingPlan.buildUrl("qwen3.7-plus", true, 0, { providerSpecificData: { region: "china-beijing" }, }), - "https://coding.dashscope.aliyuncs.com/apps/anthropic/v1/messages" + "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic/v1/messages" ); const qwenCloud = new DefaultExecutor("qwen-cloud"); @@ -133,7 +133,11 @@ test("provider validation probes the selected Coding Plan region", async () => { }, }); assert.equal(result.valid, true); - assert.deepEqual(urls, ["https://coding.dashscope.aliyuncs.com/apps/anthropic/v1/messages"]); + // The stored URL is a RETIRED preset, so it must not pin the connection: the + // china-beijing selector still wins and routes to the Token Plan CN host. + assert.deepEqual(urls, [ + "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic/v1/messages", + ]); } finally { globalThis.fetch = originalFetch; } @@ -204,6 +208,7 @@ test("Qwen Cloud is a first-class metered API-key provider", () => { assert.deepEqual( REGISTRY["qwen-cloud"].models.map((model) => model.id), [ + "qwen3.8-max", "qwen3.7-max-2026-06-08", "qwen3.7-plus", "qwen3.6-plus", @@ -223,6 +228,7 @@ test("Qwen Cloud is a first-class metered API-key provider", () => { test("Alibaba Model Studio exposes the curated modern text catalog", () => { const expectedModels = [ + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", @@ -272,19 +278,20 @@ test("Qwen Cloud Token Plan remains a flat-rate provider with chat models only", const modelIds = REGISTRY["qwen-cloud-token-plan"].models.map((model) => model.id); assert.deepEqual(modelIds, [ - "qwen3.8-max-preview", + "qwen3.8-max", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-flash", "glm-5.2", "deepseek-v4-pro", + "deepseek-v4-flash-0731", ]); - const preview = REGISTRY["qwen-cloud-token-plan"].models[0]; - assert.equal(preview.supportsReasoning, true); - assert.equal(preview.supportsVision, true); - assert.equal(preview.contextLength, 1_000_000); - assert.equal(preview.maxOutputTokens, 65_536); + const qwen38 = REGISTRY["qwen-cloud-token-plan"].models[0]; + assert.equal(qwen38.supportsReasoning, true); + assert.equal(qwen38.supportsVision, true); + assert.equal(qwen38.contextLength, 1_000_000); + assert.equal(qwen38.maxOutputTokens, 131_072); }); test("dashboard folds legacy China connections into the unified Alibaba card", () => { diff --git a/tests/unit/antigravity-claude-prefill-strip.test.ts b/tests/unit/antigravity-claude-prefill-strip.test.ts index cac8d881ef..e67ac5d83d 100644 --- a/tests/unit/antigravity-claude-prefill-strip.test.ts +++ b/tests/unit/antigravity-claude-prefill-strip.test.ts @@ -44,14 +44,42 @@ test("(a) strips a single trailing assistant (model) turn for Claude models", as assert.equal(contents.at(-1)?.role, "user"); }); -test("(b) does NOT strip a trailing model turn for non-Claude (native Gemini) models", async () => { +test("(b) strips a trailing model turn for native Gemini models too (#10104)", async () => { + // Newer Gemini endpoints reject a request ending on a `model` turn with the same + // class of 400 Claude hits via Vertex ("Requests ending with a model turn are not + // supported"), so native Gemini models routed through Antigravity get the same + // guarded strip as the Claude path. const request = await transform("antigravity/gemini-3.1-pro", [ { role: "user", parts: [{ text: "Hello" }] }, { role: "model", parts: [{ text: "Hi there" }] }, ]); const contents = request.contents as Array<{ role: string }>; - assert.equal(contents.length, 2); - assert.equal(contents.at(-1)?.role, "model", "native Gemini requests via Antigravity are untouched"); + assert.equal(contents.length, 1); + assert.equal(contents.at(-1)?.role, "user", "transformed native Gemini request must end on user"); +}); + +test("(b2) native Gemini 3.6 Flash tiers get the strip (#10104)", async () => { + for (const tier of ["high", "medium", "low"]) { + const request = await transform(`antigravity/gemini-3.6-flash-${tier}`, [ + { role: "user", parts: [{ text: "Hello" }] }, + { role: "model", parts: [{ text: "Hi there" }] }, // trailing model turn -> 400 source + ]); + const contents = request.contents as Array<{ role: string }>; + assert.equal(contents.length, 1, `${tier}: trailing model turn should be stripped`); + assert.equal(contents.at(-1)?.role, "user", `${tier}: request must end on user`); + } +}); + +test("(b3) image and older Gemini families keep their separate request contract", async () => { + for (const model of ["antigravity/gemini-3.1-flash-image", "antigravity/gemini-2.5-flash"]) { + const request = await transform(model, [ + { role: "user", parts: [{ text: "Hello" }] }, + { role: "model", parts: [{ text: "Hi there" }] }, + ]); + const contents = request.contents as Array<{ role: string }>; + assert.equal(contents.length, 2, `${model}: non-target family must be unchanged`); + assert.equal(contents.at(-1)?.role, "model", `${model}: model turn must be preserved`); + } }); test("(c) a Claude conversation already ending on user is unchanged", async () => { diff --git a/tests/unit/antigravity-model-aliases.test.ts b/tests/unit/antigravity-model-aliases.test.ts index f1e5cda7f2..ff6c5b550d 100644 --- a/tests/unit/antigravity-model-aliases.test.ts +++ b/tests/unit/antigravity-model-aliases.test.ts @@ -18,6 +18,7 @@ function getPublicModel(id: string) { } const EXPECTED_FLASH_TIERS = [ + ["gemini-3.7-flash", "Gemini 3.7 Flash"], ["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)"], @@ -49,7 +50,11 @@ test("toClientAntigravityQuotaModelId preserves upstream Gemini Flash bucket IDs test("resolveAntigravityModelId maps the documented Antigravity aliases to upstream IDs", () => { assert.equal(resolveAntigravityModelId("gemini-3-pro-image-preview"), "gemini-3-pro-image"); for (const [modelId] of EXPECTED_FLASH_TIERS) { - assert.equal(resolveAntigravityModelId(modelId), modelId); + // Only the collapsed gemini-3.7-flash id is aliased to the live upstream + // gemini-3.7-flash-tiered id; the suffixed gemini-3.7-flash-high/medium tier ids + // (like the 3.6/3.5 tiers) have no alias entry and pass through verbatim. + const expected = modelId === "gemini-3.7-flash" ? "gemini-3.7-flash-tiered" : modelId; + assert.equal(resolveAntigravityModelId(modelId), expected); } assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5"), "claude-sonnet-4-6"); assert.equal(resolveAntigravityModelId("gemini-claude-sonnet-4-5-thinking"), "claude-sonnet-4-6"); @@ -193,7 +198,7 @@ test("AntigravityExecutor.transformRequest preserves Gemini Flash upstream IDs", ); if (result instanceof Response) throw new Error("Unexpected Response from transformRequest"); - assert.equal(result.model, modelId); + assert.equal(result.model, resolveAntigravityModelId(modelId)); assert.deepEqual(result.request.contents, [{ role: "user", parts: [{ text: "Hello" }] }]); } }); diff --git a/tests/unit/api-key-scope-validation.test.ts b/tests/unit/api-key-scope-validation.test.ts index 7ce6e37db0..8b59d1e62e 100644 --- a/tests/unit/api-key-scope-validation.test.ts +++ b/tests/unit/api-key-scope-validation.test.ts @@ -12,7 +12,10 @@ import { hasSelfUsageScope, normalizeSelfServiceScopesForCreate, } from "../../src/shared/constants/selfServiceScopes.ts"; -import { createKeySchema, updateKeyPermissionsSchema } from "../../src/shared/validation/schemas.ts"; +import { + createKeySchema, + updateKeyPermissionsSchema, +} from "../../src/shared/validation/schemas.ts"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -44,6 +47,30 @@ test("api key validation accepts more than sixteen scopes", () => { assert.equal(updateKeyPermissionsSchema.safeParse({ scopes }).success, true); }); +test("lease scope requires an explicit non-empty connection allowlist", () => { + const connection = "00000000-0000-4000-8000-000000000001"; + assert.equal( + createKeySchema.safeParse({ name: "invalid managed key", scopes: ["lease:exclusive"] }).success, + false + ); + // Partial PATCH validity depends on the authoritative stored-row + mutation check. + assert.equal(updateKeyPermissionsSchema.safeParse({ scopes: ["lease:exclusive"] }).success, true); + assert.equal( + updateKeyPermissionsSchema.safeParse({ + scopes: ["lease:exclusive"], + allowedConnections: [], + }).success, + false + ); + assert.equal( + updateKeyPermissionsSchema.safeParse({ + scopes: ["lease:exclusive"], + allowedConnections: [connection], + }).success, + true + ); +}); + test("api key create route normalizes omitted scopes to self-service usage", () => { const source = fs.readFileSync(path.join(repoRoot, "src/app/api/keys/route.ts"), "utf8"); diff --git a/tests/unit/api/cli-tools/apply-container-guard.test.ts b/tests/unit/api/cli-tools/apply-container-guard.test.ts new file mode 100644 index 0000000000..5ef03013b8 --- /dev/null +++ b/tests/unit/api/cli-tools/apply-container-guard.test.ts @@ -0,0 +1,156 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; + +/** + * Container-guard homologation for POST /api/cli-tools/apply. + * + * Both runtime modes are exercised by SCOPED `OMNIROUTE_CONTAINER` overrides + * (set per test, restored in finally). The override is the documented test + * seam of `isRunningInContainer()`; it is never forced globally — forcing it + * off for the whole suite would hide a regression in the guard itself. + */ + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-apply-guard-data-")); +const TEST_XDG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-apply-guard-xdg-")); +const originalDataDir = process.env.DATA_DIR; +const originalXdg = process.env.XDG_CONFIG_HOME; +// Fresh DB without a configured password → management auth is open, so these +// tests exercise the guard, not the auth stack (covered elsewhere). +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.XDG_CONFIG_HOME = TEST_XDG_DIR; + +const core = await import("../../../../src/lib/db/core.ts"); +const { POST } = await import("../../../../src/app/api/cli-tools/apply/route.ts"); + +const OPENCODE_CONFIG = path.join(TEST_XDG_DIR, "opencode", "opencode.json"); + +// The OpenCode generator refuses to write without the live /v1/models catalog +// (context windows are catalog-sourced by design), so serve a minimal catalog +// from an in-test loopback server instead of mocking generator internals. +let catalogServer: http.Server; +let catalogBaseUrl = ""; + +function startCatalogServer(): Promise { + return new Promise((resolve) => { + catalogServer = http.createServer((req, res) => { + if (String(req.url).startsWith("/v1/models")) { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + data: [{ id: "glm/glm-5.2", object: "model", context_length: 128000 }], + }) + ); + return; + } + res.writeHead(404, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + }); + catalogServer.listen(0, "127.0.0.1", () => { + const address = catalogServer.address(); + const port = typeof address === "object" && address ? address.port : 0; + resolve(`http://127.0.0.1:${port}`); + }); + }); +} + +function applyRequest(body: Record): Request { + return new Request("http://localhost:3000/api/cli-tools/apply", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function withContainerMode(mode: "1" | "0", run: () => Promise): Promise { + const original = process.env.OMNIROUTE_CONTAINER; + const originalAllow = process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE; + process.env.OMNIROUTE_CONTAINER = mode; + delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE; + try { + return await run(); + } finally { + if (original === undefined) delete process.env.OMNIROUTE_CONTAINER; + else process.env.OMNIROUTE_CONTAINER = original; + if (originalAllow !== undefined) { + process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = originalAllow; + } + } +} + +describe("POST /api/cli-tools/apply — container guard", () => { + before(async () => { + catalogBaseUrl = await startCatalogServer(); + }); + + after(() => { + catalogServer?.close(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.rmSync(TEST_XDG_DIR, { recursive: true, force: true }); + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; + if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = originalXdg; + }); + + it("refuses an OpenCode write in container mode with a safe 422", async () => { + const res = await withContainerMode("1", () => + POST( + applyRequest({ + toolId: "opencode", + baseUrl: catalogBaseUrl, + apiKey: "sk-test-guard", + }) + ) + ); + assert.strictEqual(res.status, 422); + const body = await res.json(); + assert.ok(body.containerEphemeralTarget, "422 must be keyed as containerEphemeralTarget"); + assert.strictEqual(body.hostSetupCommand, "omniroute setup-opencode"); + assert.ok(typeof body.error === "string" && body.error.length > 0); + assert.ok(!body.error.includes("at /"), "error must not leak a stack trace"); + assert.ok(!body.error.includes("sk-test-guard"), "error must not leak the API key"); + assert.strictEqual(fs.existsSync(OPENCODE_CONFIG), false, "nothing may be written"); + }); + + it("still serves dry-run previews in container mode without writing", async () => { + const res = await withContainerMode("1", () => + POST( + applyRequest({ + toolId: "opencode", + baseUrl: catalogBaseUrl, + apiKey: "sk-test-guard", + dryRun: true, + }) + ) + ); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.dryRun, true); + assert.ok(String(body.content).includes(catalogBaseUrl)); + assert.strictEqual(fs.existsSync(OPENCODE_CONFIG), false, "dry-run must not write"); + }); + + it("writes the valid OpenCode config on a host", async () => { + const res = await withContainerMode("0", () => + POST( + applyRequest({ + toolId: "opencode", + baseUrl: catalogBaseUrl, + apiKey: "sk-test-guard", + }) + ) + ); + assert.strictEqual(res.status, 200); + const body = await res.json(); + assert.strictEqual(body.success, true); + assert.strictEqual(body.configPath, OPENCODE_CONFIG); + assert.ok(fs.existsSync(OPENCODE_CONFIG), "host write must land"); + const written = fs.readFileSync(OPENCODE_CONFIG, "utf-8"); + assert.ok(written.includes(catalogBaseUrl)); + }); +}); diff --git a/tests/unit/attempt-logging-early-keepalive-merge.test.ts b/tests/unit/attempt-logging-early-keepalive-merge.test.ts new file mode 100644 index 0000000000..da9ef6fbfe --- /dev/null +++ b/tests/unit/attempt-logging-early-keepalive-merge.test.ts @@ -0,0 +1,156 @@ +// tests/unit/attempt-logging-early-keepalive-merge.test.ts +// End-to-end proof that bytes withEarlyStreamKeepalive writes directly to +// the client (outside the handler's own reqLogger) actually reach the +// persisted call-log row's pipeline.streamChunks.client, prepended in the +// order they were sent — the gap flagged against the real 2026-08-13 +// incident: OmniRoute's own call-log artifact never showed the keepalive +// frames that were actually on the wire, only what the inner handler +// produced. Uses a real temp DB + persisted-row polling, same pattern as +// tests/unit/chatcore-attempt-logging.test.ts. +import { test, before, after } 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 testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-keepalive-merge-test-")); +process.env.DATA_DIR = testDataDir; + +const coreDb = await import("../../src/lib/db/core.ts"); +const { getCallLogById } = await import("../../src/lib/usage/callLogs.ts"); +const { persistAttemptLogs } = await import("../../open-sse/handlers/chatCore/attemptLogging.ts"); +const { recordEarlyKeepaliveBytes, takeEarlyKeepaliveBytes } = + await import("../../open-sse/utils/earlyKeepaliveByteBuffer.ts"); + +function baseCtx(overrides: Record = {}) { + return { + provider: "openai", + connectionId: "conn-1", + model: "gpt-x", + skillRequestId: "skill-1", + detailedLoggingEnabled: true, + reqLogger: null, + pendingRequestId: "REPLACE", + clientRawRequest: { endpoint: "/v1/responses" }, + requestedModel: "gpt-x-requested", + credentials: { connectionId: "conn-1" }, + startTime: Date.now(), + body: { input: [{ role: "user", content: "hi" }] }, + sourceFormat: "openai-responses", + targetFormat: "openai-responses", + comboName: null, + comboStepId: null, + comboExecutionKey: null, + tokensCompressed: 0, + apiKeyInfo: { id: "key-1", name: "Key One" }, + noLogEnabled: false, + ...overrides, + } as Parameters[1]; +} + +async function pollForCallLog(id: string, tries = 120) { + for (let i = 0; i < tries; i++) { + const row = await getCallLogById(id); + if (row) return row as Record; + await new Promise((r) => setTimeout(r, 20)); + } + return null; +} + +before(async () => { + await coreDb.ensureDbInitialized(); +}); + +after(() => { + coreDb.resetDbInstance(); + fs.rmSync(testDataDir, { recursive: true, force: true }); +}); + +test("bytes recorded before persistAttemptLogs are prepended into pipeline.streamChunks.client", async () => { + const id = "attempt-keepalive-merge-1"; + const correlationId = "corr-keepalive-merge-1"; + recordEarlyKeepaliveBytes(correlationId, "[00:00:00.100] : keepalive\n\n"); + recordEarlyKeepaliveBytes( + correlationId, + '[00:00:00.200] event: response.output_item.added\ndata: {"item":{"id":"rs_keepalive"}}\n\n' + ); + + persistAttemptLogs( + { status: 200 }, + baseCtx({ + pendingRequestId: id, + correlationId, + reqLogger: { + getPipelinePayloads: () => ({ + streamChunks: { client: ["[00:00:05.000] real body chunk"] }, + }), + }, + }) + ); + + const row = await pollForCallLog(id); + assert.ok(row, "call log row should be persisted"); + const pipeline = row.pipelinePayloads as { streamChunks?: { client?: string[] } }; + assert.deepEqual(pipeline.streamChunks?.client, [ + "[00:00:00.100] : keepalive\n\n", + '[00:00:00.200] event: response.output_item.added\ndata: {"item":{"id":"rs_keepalive"}}\n\n', + "[00:00:05.000] real body chunk", + ]); +}); + +test("bytes are consumed exactly once — a repeat lookup for the same correlationId finds nothing left to merge", async () => { + const correlationId = "corr-keepalive-merge-2"; + recordEarlyKeepaliveBytes(correlationId, "[00:00:00.100] : keepalive\n\n"); + + const first = takeEarlyKeepaliveBytes(correlationId); + assert.equal(first.length, 1); + + const second = takeEarlyKeepaliveBytes(correlationId); + assert.deepEqual(second, []); +}); + +test("no correlationId on the attempt means no merge is attempted (existing streamChunks untouched)", async () => { + const id = "attempt-keepalive-merge-3"; + // No recordEarlyKeepaliveBytes call at all for this id/correlationId — proves + // the merge path is a strict no-op, not a silent create-empty-array side effect. + persistAttemptLogs( + { status: 200 }, + baseCtx({ + pendingRequestId: id, + correlationId: null, + reqLogger: { + getPipelinePayloads: () => ({ + streamChunks: { client: ["[00:00:05.000] real body chunk"] }, + }), + }, + }) + ); + + const row = await pollForCallLog(id); + assert.ok(row); + const pipeline = row.pipelinePayloads as { streamChunks?: { client?: string[] } }; + assert.deepEqual(pipeline.streamChunks?.client, ["[00:00:05.000] real body chunk"]); +}); + +test("detailedLoggingEnabled=false skips the merge even when early bytes are buffered (matches the existing streamChunks capture gate)", async () => { + const id = "attempt-keepalive-merge-4"; + const correlationId = "corr-keepalive-merge-4"; + recordEarlyKeepaliveBytes(correlationId, "[00:00:00.100] : keepalive\n\n"); + + persistAttemptLogs( + { status: 200 }, + baseCtx({ + pendingRequestId: id, + correlationId, + detailedLoggingEnabled: false, + reqLogger: null, + }) + ); + + const row = await pollForCallLog(id); + assert.ok(row); + // Buffer must still hold the entry — a disabled-detailed-logging attempt + // must not silently drain another (later, detailed-logging-enabled) attempt's + // buffered bytes out from under it. + assert.equal(takeEarlyKeepaliveBytes(correlationId).length, 1); +}); diff --git a/tests/unit/audio-nested-model-credential-fallback.test.ts b/tests/unit/audio-nested-model-credential-fallback.test.ts new file mode 100644 index 0000000000..cae83e3704 --- /dev/null +++ b/tests/unit/audio-nested-model-credential-fallback.test.ts @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + parseTranscriptionModel, + AUDIO_TRANSCRIPTION_PROVIDERS, + audioModelAliasCandidates, + findAlternateAudioProvider, + listAlternateAudioModelIds, + missingAudioProviderCredentialsMessage, +} = await import("../../open-sse/config/audioRegistry.ts"); + +test("parseTranscriptionModel prefix-matches native Deepgram for deepgram/nova-3", () => { + assert.deepEqual(parseTranscriptionModel("deepgram/nova-3"), { + provider: "deepgram", + model: "nova-3", + }); +}); + +test("parseTranscriptionModel keeps OpenRouter when the id is qualified", () => { + assert.deepEqual(parseTranscriptionModel("openrouter/deepgram/nova-3"), { + provider: "openrouter", + model: "deepgram/nova-3", + }); +}); + +test("findAlternateAudioProvider maps native Deepgram nova-3 to OpenRouter", () => { + const candidates = audioModelAliasCandidates("deepgram/nova-3", "deepgram", "nova-3"); + const alternate = findAlternateAudioProvider( + AUDIO_TRANSCRIPTION_PROVIDERS, + "deepgram", + candidates + ); + assert.ok(alternate); + assert.equal(alternate?.provider, "openrouter"); + assert.equal(alternate?.model, "deepgram/nova-3"); +}); + +test("findAlternateAudioProvider maps bare whisper-1 to OpenRouter when OpenAI has no creds", () => { + // Scoped to a 2-provider registry (rather than the live, growing + // AUDIO_TRANSCRIPTION_PROVIDERS) so this stays a deterministic test of the + // *qualified*-alias fallback branch (`${failedProvider}/${resolvedModel}`) + // regardless of future providers that also list a bare "whisper-1" id + // (e.g. nanogpt) and would otherwise intercept on the first candidate. + const scopedRegistry = { + openai: AUDIO_TRANSCRIPTION_PROVIDERS.openai, + openrouter: AUDIO_TRANSCRIPTION_PROVIDERS.openrouter, + }; + const candidates = audioModelAliasCandidates("whisper-1", "openai", "whisper-1"); + const alternate = findAlternateAudioProvider(scopedRegistry, "openai", candidates); + assert.ok(alternate); + assert.equal(alternate?.provider, "openrouter"); + assert.equal(alternate?.model, "openai/whisper-1"); +}); + +test("findAlternateAudioProvider returns null when no other provider lists the model", () => { + const alternate = findAlternateAudioProvider(AUDIO_TRANSCRIPTION_PROVIDERS, "assemblyai", [ + "universal-3-pro", + "assemblyai/universal-3-pro", + ]); + assert.equal(alternate, null); +}); + +test("missing-credential error lists qualified catalog aliases", () => { + const candidates = audioModelAliasCandidates("deepgram/nova-3", "deepgram", "nova-3"); + const ids = listAlternateAudioModelIds(AUDIO_TRANSCRIPTION_PROVIDERS, "deepgram", candidates); + assert.ok(ids.includes("openrouter/deepgram/nova-3")); + assert.match( + missingAudioProviderCredentialsMessage("deepgram", ids), + /No credentials for provider: deepgram.*openrouter\/deepgram\/nova-3/ + ); +}); diff --git a/tests/unit/audio-transcription-opus-filename.test.ts b/tests/unit/audio-transcription-opus-filename.test.ts new file mode 100644 index 0000000000..f54af8df68 --- /dev/null +++ b/tests/unit/audio-transcription-opus-filename.test.ts @@ -0,0 +1,88 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildMultipartBody } = await import("../../open-sse/handlers/audioTranscription.ts"); +const { resolveOpenRouterAudioFormat } = await import( + "../../open-sse/handlers/openrouterTranscription.ts" +); + +/** + * `.opus` is Opus audio in an Ogg container (RFC 7845) — byte-identical to what + * a client would name `.ogg`. Whisper-compatible upstreams select the decoder + * from the multipart *filename* against an allow-list + * (`flac, m4a, mp3, mp4, mpeg, mpga, oga, ogg, wav, webm`) that has no `opus`, + * so `note.opus` used to 400 while the same bytes as `note.ogg` transcribed + * fine. `/v1/audio/speech` emits `audio/opus` for `response_format=opus`, so + * clients re-uploading their own voice notes hit it constantly (#10588). + */ + +function audioFile(name: string, type = "") { + return Object.assign(new Blob([new Uint8Array([1, 2, 3, 4])], { type }), { name }); +} + +function filenameIn(body: Uint8Array): string { + const header = new TextDecoder().decode(body); + return /filename="([^"]*)"/.exec(header)?.[1] ?? ""; +} + +test("a .opus upload is announced to the upstream as .ogg", async () => { + const { body } = await buildMultipartBody(audioFile("note.opus", "audio/opus"), { + model: "whisper-1", + }); + + assert.equal(filenameIn(body), "note.ogg"); +}); + +test("the rewrite is case-insensitive and keeps the rest of the name intact", async () => { + const { body } = await buildMultipartBody(audioFile("Voice Note 2026.OPUS"), { + model: "whisper-1", + }); + + assert.equal(filenameIn(body), "Voice Note 2026.ogg"); +}); + +test("only a trailing .opus is rewritten — not one mid-name", async () => { + // `opus` appearing anywhere else is part of the name, not the container. + const { body } = await buildMultipartBody(audioFile("opus-demo.wav"), { model: "whisper-1" }); + assert.equal(filenameIn(body), "opus-demo.wav"); + + const nested = await buildMultipartBody(audioFile("take.opus.mp3"), { model: "whisper-1" }); + assert.equal(filenameIn(nested.body), "take.opus.mp3"); +}); + +test("other extensions are forwarded unchanged", async () => { + for (const name of ["note.ogg", "note.mp3", "note.wav", "note.webm"]) { + const { body } = await buildMultipartBody(audioFile(name), { model: "whisper-1" }); + assert.equal(filenameIn(body), name); + } +}); + +test("a nameless blob still falls back to audio.wav", async () => { + const blob = new Blob([new Uint8Array([1, 2, 3, 4])], { type: "audio/wav" }); + const { body } = await buildMultipartBody(blob as Blob & { name?: unknown }, { + model: "whisper-1", + }); + + assert.equal(filenameIn(body), "audio.wav"); +}); + +/** + * The OpenRouter STT endpoint takes the container as a JSON field rather than a + * filename. `.opus` matched neither its extension list nor its MIME map, so it + * fell through to the `"wav"` default — announcing Opus bytes as WAV. + */ +test("OpenRouter STT resolves .opus to its ogg container, not the wav default", () => { + assert.equal(resolveOpenRouterAudioFormat(audioFile("note.opus")), "ogg"); + assert.equal(resolveOpenRouterAudioFormat(audioFile("note.OPUS")), "ogg"); +}); + +test("OpenRouter STT resolves an audio/opus MIME to ogg", () => { + assert.equal(resolveOpenRouterAudioFormat(audioFile("blob", "audio/opus")), "ogg"); +}); + +test("OpenRouter STT still resolves the formats it already supported", () => { + assert.equal(resolveOpenRouterAudioFormat(audioFile("a.ogg")), "ogg"); + assert.equal(resolveOpenRouterAudioFormat(audioFile("a.mp3")), "mp3"); + assert.equal(resolveOpenRouterAudioFormat(audioFile("blob", "audio/webm;codecs=opus")), "webm"); + assert.equal(resolveOpenRouterAudioFormat(audioFile("mystery.xyz")), "wav"); +}); diff --git a/tests/unit/auth-log-account-id-redaction-10539.test.ts b/tests/unit/auth-log-account-id-redaction-10539.test.ts new file mode 100644 index 0000000000..f31508039b --- /dev/null +++ b/tests/unit/auth-log-account-id-redaction-10539.test.ts @@ -0,0 +1,98 @@ +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 { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +// Regression guard for #10348/#10539: the AUTH log line in the SSE chat +// handler ("Using account: ...") must redact the account +// prefix by default. Redaction MUST be governed by a narrow, dedicated +// feature flag (AUTH_LOG_INCLUDE_ACCOUNT_ID) — NOT by the broad `debugMode` +// setting. `debugMode` is a general dashboard-visibility toggle unrelated to +// log privacy (its own default has flipped independently more than once, +// see #10372/#10312); deriving log redaction from it means any future, +// unrelated change to debugMode's default silently changes whether account +// prefixes leak into logs. The narrow flag keeps the two concerns separate. + +// Isolate DB state so the resolution chain (DB override > env > default) +// reads a clean store and we exercise the definition default, not a leaked +// override from another test file. +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-auth-log-account-id-")); +process.env.DATA_DIR = tmpDir; + +const { FEATURE_FLAG_DEFINITIONS } = await import( + "../../src/shared/constants/featureFlagDefinitions.ts" +); + +test("AUTH_LOG_INCLUDE_ACCOUNT_ID feature flag defaults to OFF, independent of debugMode", async (t) => { + const def = (key: string) => FEATURE_FLAG_DEFINITIONS.find((d) => d.key === key); + + await t.test("AUTH_LOG_INCLUDE_ACCOUNT_ID definition exists", () => { + assert.ok( + def("AUTH_LOG_INCLUDE_ACCOUNT_ID"), + "AUTH_LOG_INCLUDE_ACCOUNT_ID definition must exist in FEATURE_FLAG_DEFINITIONS" + ); + }); + + await t.test("AUTH_LOG_INCLUDE_ACCOUNT_ID default value is 'false'", () => { + assert.strictEqual( + def("AUTH_LOG_INCLUDE_ACCOUNT_ID")!.defaultValue, + "false", + "AUTH_LOG_INCLUDE_ACCOUNT_ID must default OFF — account prefixes are redacted by default" + ); + }); + + await t.test("AUTH_LOG_INCLUDE_ACCOUNT_ID category is 'security'", () => { + assert.strictEqual(def("AUTH_LOG_INCLUDE_ACCOUNT_ID")!.category, "security"); + }); + + await t.test("AUTH_LOG_INCLUDE_ACCOUNT_ID type is 'boolean'", () => { + assert.strictEqual(def("AUTH_LOG_INCLUDE_ACCOUNT_ID")!.type, "boolean"); + }); + + await t.test("effective runtime resolution is OFF with no override", async () => { + delete process.env.AUTH_LOG_INCLUDE_ACCOUNT_ID; + const { clearAllFeatureFlagOverrides } = await import("@/lib/db/featureFlags"); + clearAllFeatureFlagOverrides(); + + const { isFeatureFlagEnabled } = await import("@/shared/utils/featureFlags"); + assert.strictEqual(isFeatureFlagEnabled("AUTH_LOG_INCLUDE_ACCOUNT_ID"), false); + }); + + await t.test("explicit env override enables it", async () => { + process.env.AUTH_LOG_INCLUDE_ACCOUNT_ID = "true"; + try { + const { isFeatureFlagEnabled } = await import("@/shared/utils/featureFlags"); + assert.strictEqual(isFeatureFlagEnabled("AUTH_LOG_INCLUDE_ACCOUNT_ID"), true); + } finally { + delete process.env.AUTH_LOG_INCLUDE_ACCOUNT_ID; + } + }); +}); + +test("chat.ts AUTH account log line is gated on the narrow flag, not on debugMode", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const chatHandlerPath = resolve(here, "../../src/sse/handlers/chat.ts"); + const src = fs.readFileSync(chatHandlerPath, "utf8"); + + assert.match( + src, + /Using \$\{provider\} account: \$\{includeAccountId \? accountId : "\*\*\*"\}/, + "chat.ts must redact the account prefix behind a boolean gate variable" + ); + + assert.match( + src, + /isFeatureFlagEnabled\("AUTH_LOG_INCLUDE_ACCOUNT_ID"\)/, + "chat.ts must resolve the redaction gate via the narrow AUTH_LOG_INCLUDE_ACCOUNT_ID flag" + ); + + // The old, broad debugMode-derived gate must be gone from this call site. + assert.doesNotMatch( + src, + /debugMode === true[\s\S]{0,80}Using \$\{provider\} account/, + "the AUTH account log line must not be gated on the broad debugMode setting" + ); +}); diff --git a/tests/unit/auth-noauth-fallback-loop-3061.test.ts b/tests/unit/auth-noauth-fallback-loop-3061.test.ts index b9d23eb3ec..d640ffb1e2 100644 --- a/tests/unit/auth-noauth-fallback-loop-3061.test.ts +++ b/tests/unit/auth-noauth-fallback-loop-3061.test.ts @@ -49,13 +49,6 @@ test("#3061 opencode-zen no-auth: first selection returns synthetic noauth (happ assert.equal((creds as { connectionId?: string }).connectionId, "noauth"); }); -test("#3061 mimocode no-auth: first selection returns synthetic noauth (happy path preserved)", async () => { - const creds = await getProviderCredentials("mimocode", null, null, "mimo-auto"); - assert.ok(creds, "mimocode must resolve to synthetic no-auth credentials on first selection"); - assert.equal((creds as { connectionId?: string }).connectionId, "noauth"); - assert.equal((creds as { apiKey?: unknown }).apiKey, null); -}); - // ── The fix: once "noauth" is excluded, selection MUST stop (return null) ── test("#3061 opencode no-auth: excluding 'noauth' returns null (breaks the fallback loop)", async () => { @@ -81,13 +74,3 @@ test("#3061 opencode-zen no-auth: excluding 'noauth' returns null (breaks the fa ); }); -test("#3061 mimocode no-auth: excluding 'noauth' returns null (breaks the fallback loop)", async () => { - const creds = await getProviderCredentials("mimocode", null, null, "mimo-auto", { - excludeConnectionIds: ["noauth"], - }); - assert.equal( - creds, - null, - "excluded synthetic noauth must not be re-selected for the mimocode keyless path" - ); -}); diff --git a/tests/unit/auto-disable-banned.test.ts b/tests/unit/auto-disable-banned.test.ts new file mode 100644 index 0000000000..bb261991d5 --- /dev/null +++ b/tests/unit/auto-disable-banned.test.ts @@ -0,0 +1,164 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { updateAutoDisableAccountsSchema } from "../../src/shared/validation/schemas/settings.ts"; +import { + isSubscriptionStyleConnection, + normalizeAutoDisableBannedScope, + shouldAutoDisableBannedConnection, +} from "../../src/shared/utils/autoDisableBanned.ts"; + +test("normalizeAutoDisableBannedScope defaults unknown values to all", () => { + assert.equal(normalizeAutoDisableBannedScope(undefined), "all"); + assert.equal(normalizeAutoDisableBannedScope("all"), "all"); + assert.equal(normalizeAutoDisableBannedScope("subscription"), "subscription"); + assert.equal(normalizeAutoDisableBannedScope("nope"), "all"); +}); + +test("shouldAutoDisableBannedConnection is off when the feature is disabled", () => { + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: false, + scope: "all", + authType: "oauth", + }), + false + ); +}); + +test("scope=all deactivates API keys and OAuth connections", () => { + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "all", + authType: "apikey", + }), + true + ); + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + authType: "oauth", + }), + true + ); +}); + +test("scope=subscription skips prepaid API keys", () => { + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "subscription", + authType: "apikey", + providerId: "jina-ai", + }), + false + ); + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "subscription", + authType: "api_key", + }), + false + ); +}); + +test("scope=subscription still deactivates OAuth and cookie accounts", () => { + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "subscription", + authType: "oauth", + }), + true + ); + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "subscription", + authType: "cookie", + }), + true + ); + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "subscription", + authType: "access_token", + }), + true + ); + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "subscription", + authType: "session", + }), + true + ); + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "subscription", + authType: "web", + }), + true + ); +}); + +test("scope=subscription treats web-cookie providers as subscriptions even when authType is apikey", () => { + assert.equal( + isSubscriptionStyleConnection({ + authType: "apikey", + providerId: "grok-web", + webCookieProviderIds: { "grok-web": {} }, + }), + true + ); + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "subscription", + authType: "apikey", + providerId: "grok-web", + webCookieProviderIds: { "grok-web": {} }, + }), + true + ); +}); + +test("auto-disable settings schema accepts scope and rejects unknown values", () => { + assert.equal( + updateAutoDisableAccountsSchema.safeParse({ + enabled: true, + threshold: 2, + scope: "subscription", + }).success, + true + ); + assert.equal( + updateAutoDisableAccountsSchema.safeParse({ + enabled: true, + scope: "all", + }).success, + true + ); + assert.equal( + updateAutoDisableAccountsSchema.safeParse({ + enabled: true, + scope: "provider", + }).success, + false + ); +}); + +test("unknown auth types stay conservative and still auto-disable", () => { + assert.equal( + shouldAutoDisableBannedConnection({ + enabled: true, + scope: "subscription", + authType: "mystery", + }), + true + ); +}); diff --git a/tests/unit/auto-empty-pool-warn-once.test.ts b/tests/unit/auto-empty-pool-warn-once.test.ts new file mode 100644 index 0000000000..016b2e0997 --- /dev/null +++ b/tests/unit/auto-empty-pool-warn-once.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + EMPTY_POOL_WARN_INTERVAL_MS, + resetEmptyAutoPoolWarnStateForTests, + warnEmptyAutoPoolOnce, +} from "../../open-sse/services/autoCombo/virtualFactory.ts"; + +test("warnEmptyAutoPoolOnce emits at most once per label per interval", () => { + resetEmptyAutoPoolWarnStateForTests(); + const t0 = 1_000_000; + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0), true); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + 1), false); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + EMPTY_POOL_WARN_INTERVAL_MS - 1), false); + assert.equal(warnEmptyAutoPoolOnce("auto/other", "empty", t0 + 1), true); + assert.equal(warnEmptyAutoPoolOnce("auto/zai", "empty", t0 + EMPTY_POOL_WARN_INTERVAL_MS), true); +}); diff --git a/tests/unit/autoCombo/suffixComposition-4517.test.ts b/tests/unit/autoCombo/suffixComposition-4517.test.ts index 88a4adf333..2db7b079e9 100644 --- a/tests/unit/autoCombo/suffixComposition-4517.test.ts +++ b/tests/unit/autoCombo/suffixComposition-4517.test.ts @@ -47,15 +47,14 @@ describe("suffixComposition :free tier (#4517)", () => { }); it("buildAutoCandidateFilter keeps noAuth free providers", () => { - // Regression: opencode and mimocode are noAuth and free, but the - // pre-fix `freeProviders` list omitted them, so the filter rejected - // their candidates even though they ARE free upstream. + // Regression: opencode was noAuth and free, but the + // pre-fix `freeProviders` list omitted it, so the filter rejected + // its candidates even though it IS free upstream. const filter = buildAutoCandidateFilter("coding", "free"); assert.notEqual(filter, null); assert.equal(filter!({ provider: "opencode", model: "big-pickle" }), true); assert.equal(filter!({ provider: "opencode", model: "minimax-m3-free" }), true); - assert.equal(filter!({ provider: "mimocode", model: "mimo-auto" }), true); assert.equal(filter!({ provider: "duckduckgo-web", model: "gpt-4o-mini" }), true); }); diff --git a/tests/unit/bailian-coding-plan-provider.test.ts b/tests/unit/bailian-coding-plan-provider.test.ts index a74fd53099..757578f214 100644 --- a/tests/unit/bailian-coding-plan-provider.test.ts +++ b/tests/unit/bailian-coding-plan-provider.test.ts @@ -34,7 +34,7 @@ test("bailian-coding-plan not in OAUTH_PROVIDERS", () => { }); // Schema validation tests for providerSpecificData.baseUrl -const VALID_BAILIAN_URL = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"; +const VALID_BAILIAN_URL = "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1"; test("createProviderSchema accepts valid baseUrl in providerSpecificData", () => { const validation = validateBody(createProviderSchema, { @@ -427,7 +427,7 @@ test("validateProviderApiKey returns invalid for 401 response (bailian-coding-pl provider: "bailian-coding-plan", apiKey: "invalid-key", providerSpecificData: { - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", }, }); @@ -452,7 +452,7 @@ test("validateProviderApiKey returns invalid for 403 response (bailian-coding-pl provider: "bailian-coding-plan", apiKey: "forbidden-key", providerSpecificData: { - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", }, }); @@ -479,7 +479,7 @@ test("validateProviderApiKey returns valid for 400 response (bailian-coding-plan provider: "bailian-coding-plan", apiKey: "valid-key", providerSpecificData: { - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", }, }); @@ -508,7 +508,7 @@ test("validateProviderApiKey returns valid for 200 response (bailian-coding-plan provider: "bailian-coding-plan", apiKey: "valid-key", providerSpecificData: { - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", }, }); @@ -533,7 +533,7 @@ test("validateProviderApiKey returns invalid for 500 response (bailian-coding-pl provider: "bailian-coding-plan", apiKey: "bad-key", providerSpecificData: { - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", }, }); @@ -561,7 +561,7 @@ test("validateProviderApiKey avoids double /messages suffix for bailian-coding-p provider: "bailian-coding-plan", apiKey: "valid-key", providerSpecificData: { - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1/messages", }, }); @@ -569,7 +569,7 @@ test("validateProviderApiKey avoids double /messages suffix for bailian-coding-p assert.equal(urls.length, 1); assert.equal( urls[0], - "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages", + "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1/messages", "Should probe exactly one /messages suffix" ); } finally { @@ -588,7 +588,7 @@ test("POST /api/providers validation: bailian-coding-plan with baseUrl passes sc apiKey: "sk-placeholder-key", name: "Test Bailian Provider", providerSpecificData: { - baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1", + baseUrl: "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1", }, }); @@ -597,7 +597,7 @@ test("POST /api/providers validation: bailian-coding-plan with baseUrl passes sc assert.equal(validation.data.provider, "bailian-coding-plan"); assert.equal( validation.data.providerSpecificData?.baseUrl, - "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1" + "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic/v1" ); } }); diff --git a/tests/unit/bailian-token-plan-endpoint-parity.test.ts b/tests/unit/bailian-token-plan-endpoint-parity.test.ts new file mode 100644 index 0000000000..49ccee3bf9 --- /dev/null +++ b/tests/unit/bailian-token-plan-endpoint-parity.test.ts @@ -0,0 +1,102 @@ +/** + * bailian-coding-plan ("Alibaba Token Plan") pointed inference and validation at two + * DIFFERENT hosts. + * + * #10290 moved the open-sse registry to the Token Plan host, but the dashboard's key + * validation resolves its URL through ALIBABA_PROVIDER_REGION_ENDPOINTS, which still held + * the legacy Coding Plan host. Verified live 2026-08-18 with a valid Token Plan key: + * + * coding-intl.dashscope.aliyuncs.com → 401 invalid_api_key + * token-plan.ap-southeast-1.maas... → 429 Throttling.AllocationQuota (auth OK) + * + * validateBailianCodingPlanProvider maps 401/403 to "Invalid API key", so a perfectly + * good key was rejected at add-connection time while the very same key worked for + * inference. These tests pin the two paths together. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { REGISTRY } from "../../open-sse/config/providers/index.ts"; +import { PROVIDER_ENDPOINTS } from "../../src/shared/constants/config.ts"; +import { DEFAULT_PROVIDER_BASE_URLS } from "../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts"; +import { + ALIBABA_PROVIDER_ENDPOINTS, + resolveAlibabaProviderBaseUrl, +} from "../../src/shared/constants/alibabaProviderRegions.ts"; + +const LEGACY_CODING_PLAN_HOST = "coding-intl.dashscope.aliyuncs.com"; + +test("validation resolves the same host the inference registry dispatches to", () => { + const registryBaseUrl = REGISTRY["bailian-coding-plan"].baseUrl; + const resolved = resolveAlibabaProviderBaseUrl("bailian-coding-plan", { + region: "global-sg", + }); + + assert.equal( + resolved, + registryBaseUrl, + "the dashboard would validate the key against a different host than inference uses" + ); +}); + +test("no default endpoint still points at the Coding Plan host", () => { + // The catalog entry is a TOKEN Plan; Coding Plan keys are a different product and the + // legacy host rejects Token Plan keys outright. Compare parsed hostnames, not URL + // substrings (CodeQL js/incomplete-url-substring-sanitization). + assert.notEqual( + new URL(PROVIDER_ENDPOINTS["bailian-coding-plan"]).hostname, + LEGACY_CODING_PLAN_HOST, + "PROVIDER_ENDPOINTS still defaults to the legacy Coding Plan host" + ); + assert.notEqual( + new URL(DEFAULT_PROVIDER_BASE_URLS["bailian-coding-plan"]).hostname, + LEGACY_CODING_PLAN_HOST, + "the dashboard base-URL placeholder still shows the legacy Coding Plan host" + ); + for (const region of ["global-sg", "china-beijing"] as const) { + assert.notEqual( + new URL(ALIBABA_PROVIDER_ENDPOINTS["bailian-coding-plan"][region]).hostname, + LEGACY_CODING_PLAN_HOST, + `region ${region} still maps to the legacy Coding Plan host` + ); + } +}); + +test("both regions keep the Anthropic-compatible path the claude format requires", () => { + // format: "claude" + chatPath "/messages" — a compatible-mode URL here would 404. + for (const region of ["global-sg", "china-beijing"] as const) { + assert.ok( + ALIBABA_PROVIDER_ENDPOINTS["bailian-coding-plan"][region].endsWith("/apps/anthropic/v1"), + `region ${region} must keep the /apps/anthropic/v1 root` + ); + } +}); + +test("a saved legacy preset URL still follows the region selector", () => { + // Migration guard: connections created before the fix carry the legacy host in + // providerSpecificData.baseUrl. isFamilyPresetUrl must keep recognizing it as a + // preset — otherwise it is treated as a deliberate custom URL and the connection + // stays pinned to the host that rejects its key, with no way out but manual editing. + const legacyPreset = "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"; + + assert.equal( + resolveAlibabaProviderBaseUrl("bailian-coding-plan", { + region: "global-sg", + baseUrl: legacyPreset, + }), + ALIBABA_PROVIDER_ENDPOINTS["bailian-coding-plan"]["global-sg"], + "a stored legacy preset must not pin the connection to the dead host" + ); +}); + +test("a genuinely custom base URL still wins over the region preset", () => { + const custom = "https://my-gateway.internal/apps/anthropic/v1"; + assert.equal( + resolveAlibabaProviderBaseUrl("bailian-coding-plan", { + region: "global-sg", + baseUrl: custom, + }), + custom + ); +}); diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts index ba7ce28955..282b908246 100644 --- a/tests/unit/base-executor-sanitize-effort.test.ts +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -676,11 +676,9 @@ test("sanitizeReasoningEffortForProvider: NVIDIA GLM-5.2 mapping is narrowly sco }); // ── Native DeepSeek (api.deepseek.com) ─────────────────────────────────────── -// DeepSeek V4 thinking mode accepts reasoning_effort ONLY as {high, max}. The -// internal OmniRoute scale (low|medium|high|xhigh, xhigh = top) must be mapped -// onto DeepSeek's native vocabulary so the client's requested effort is honored -// instead of silently dropped to the default. This is the INVERSE of the -// OpenRouter-DeepSeek path, whose normalized API expects xhigh, not max. +// DeepSeek V4 thinking mode accepts reasoning_effort as {low, high, max}. +// The internal OmniRoute scale maps medium → high and xhigh → max so the client's +// requested effort is honored instead of silently dropped to the default. test("sanitizeReasoningEffortForProvider: native deepseek maps xhigh → max", () => { const log = makeLog(); @@ -716,19 +714,25 @@ test("sanitizeReasoningEffortForProvider: native deepseek preserves max", () => assert.equal(log.messages.length, 0); }); -test("sanitizeReasoningEffortForProvider: native deepseek clamps low → high", () => { +test("sanitizeReasoningEffortForProvider: native deepseek preserves low", () => { const body = { model: "deepseek-v4-pro", reasoning_effort: "low", messages: [{ role: "user", content: "hi" }], }; const result = sanitizeReasoningEffortForProvider(body, "deepseek", "deepseek-v4-pro", null); + assert.equal(result, body, "low is already valid — passes through unchanged"); +}); + +test("sanitizeReasoningEffortForProvider: native non-V4 deepseek clamps low → high", () => { + const body = { + model: "deepseek-chat", + reasoning_effort: "low", + messages: [{ role: "user", content: "hi" }], + }; + const result = sanitizeReasoningEffortForProvider(body, "deepseek", "deepseek-chat", null); assert.notEqual(result, body, "must return a new object when mutating"); - assert.equal( - (result as Record).reasoning_effort, - "high", - "below the {high, max} floor → high" - ); + assert.equal((result as Record).reasoning_effort, "high"); }); test("sanitizeReasoningEffortForProvider: native deepseek clamps medium → high", () => { @@ -786,11 +790,10 @@ test("sanitizeReasoningEffortForProvider: OpenRouter DeepSeek still preserves xh assert.equal((result as Record).reasoning_effort, "xhigh"); }); -// ── opencode-go DeepSeek V4 Pro effort variants (#4647) ────────────────────── -// opencode-go proxies DeepSeek with the native DeepSeek API contract, which -// accepts {high, max} literally. The OpencodeExecutor's transformRequest sets -// reasoning_effort to the variant suffix (low|medium|high|max), and the -// sanitizer must NOT rewrite `max` → `xhigh` for this provider+model combo. +// ── opencode-go DeepSeek V4 effort variants (#4647) ────────────────────────── +// opencode-go proxies DeepSeek with the native DeepSeek API contract. Both V4 +// models advertise none/low/high/max, and the sanitizer must preserve those +// literal values rather than rewriting `max` to `xhigh`. test("sanitizeReasoningEffortForProvider: opencode-go DeepSeek V4 Pro preserves max", () => { const body = { @@ -803,24 +806,26 @@ test("sanitizeReasoningEffortForProvider: opencode-go DeepSeek V4 Pro preserves assert.equal((result as Record).reasoning_effort, "max"); }); -test("sanitizeReasoningEffortForProvider: opencode-go DeepSeek V4 Pro preserves variant suffix levels", () => { - for (const level of ["low", "medium", "high", "max"]) { - const body = { - model: `deepseek-v4-pro-${level}`, - reasoning_effort: level, - messages: [], - }; - const result = sanitizeReasoningEffortForProvider( - body, - "opencode-go", - `deepseek-v4-pro-${level}`, - null - ); - assert.equal( - (result as Record).reasoning_effort, - level, - `opencode-go deepseek-v4-pro-${level} preserves reasoning_effort=${level}` - ); +test("sanitizeReasoningEffortForProvider: opencode-go preserves both V4 models' tiers", () => { + for (const model of ["deepseek-v4-pro", "deepseek-v4-flash"]) { + for (const level of ["none", "low", "high", "max"]) { + const body = { + model: `${model}-${level}`, + reasoning_effort: level, + messages: [], + }; + const result = sanitizeReasoningEffortForProvider( + body, + "opencode-go", + `${model}-${level}`, + null + ); + assert.equal( + (result as Record).reasoning_effort, + level, + `opencode-go ${model}-${level} preserves reasoning_effort=${level}` + ); + } } }); diff --git a/tests/unit/binaryManager.test.ts b/tests/unit/binaryManager.test.ts index dd0acba78b..4165a326c7 100644 --- a/tests/unit/binaryManager.test.ts +++ b/tests/unit/binaryManager.test.ts @@ -1,4 +1,4 @@ -import { describe, it, afterEach, after } from "node:test"; +import { describe, it, afterEach, after, mock } from "node:test"; import assert from "node:assert/strict"; import path from "node:path"; import fs from "node:fs"; @@ -26,6 +26,7 @@ describe("binaryManager", () => { mod = await import("../../src/lib/versionManager/binaryManager.ts"); assert.ok(mod.getAssetName); assert.ok(mod.getTargetPlatform); + assert.ok(mod.downloadRelease); assert.ok(mod.installVersion); assert.ok(mod.getCurrentBinaryPath); assert.ok(mod.getInstalledVersions); @@ -63,6 +64,24 @@ describe("binaryManager", () => { assert.ok(["linux", "darwin", "windows"].includes(platform)); assert.ok(["amd64", "arm64"].includes(arch)); }); + + it("should read platform/arch at runtime from os (anti build-folding guard) (#10244)", () => { + // Regression guard for #10244/#10293: detectPlatform/detectArch must read + // os.platform()/os.arch() at call time, NOT the build-machine foldable + // process.platform/process.arch constants. Turbopack `next build` running + // on Linux constant-folds `process.platform` and prunes every Windows/arm64 + // branch from the published npm artifact. Simulate a Windows arm64 host via + // the runtime os.* functions; the Windows/arm64 branch must be reachable. + const platformMock = mock.method(os, "platform", () => "win32"); + const archMock = mock.method(os, "arch", () => "arm64"); + try { + assert.deepEqual(mod.getTargetPlatform(), { platform: "windows", arch: "arm64" }); + assert.equal(mod.getAssetName(), "CLIProxyAPI_{version}_windows_arm64.zip"); + } finally { + platformMock.mock.restore(); + archMock.mock.restore(); + } + }); }); describe("getCurrentBinaryPath", () => { @@ -139,6 +158,171 @@ describe("binaryManager", () => { assert.ok(real.includes("1.0.0")); } }); + + it("should use the runtime Windows path for extraction, install, and rollback", async () => { + const binDir = path.join(tmpDir, "bin"); + const fakePowerShellDir = path.join(tmpDir, "fake-powershell"); + const extractedDir = path.join(binDir, "cliproxyapi-1.0.0"); + const commandLog = path.join(tmpDir, "powershell-command.txt"); + const originalPath = process.env.PATH; + const originalFetch = globalThis.fetch; + + fs.mkdirSync(fakePowerShellDir, { recursive: true }); + fs.writeFileSync( + path.join(fakePowerShellDir, "powershell"), + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$OMNI_TEST_COMMAND_LOG\"\n" + + "mkdir -p \"$OMNI_TEST_EXTRACT_DIR\"\nprintf 'installed-binary' > \"$OMNI_TEST_EXTRACT_DIR/cli-proxy-api\"\n" + ); + fs.chmodSync(path.join(fakePowerShellDir, "powershell"), 0o755); + process.env.PATH = `${fakePowerShellDir}:${originalPath || ""}`; + process.env.OMNI_TEST_COMMAND_LOG = commandLog; + process.env.OMNI_TEST_EXTRACT_DIR = extractedDir; + + globalThis.fetch = async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("/releases/tags/")) { + return new Response( + JSON.stringify({ + tag_name: "v1.0.0", + published_at: "2026-01-01T00:00:00Z", + assets: [ + { + name: "CLIProxyAPI_1.0.0_windows_amd64.zip", + browser_download_url: "https://example.test/cliproxy.zip", + size: 3, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url.endsWith("checksums.txt")) return new Response("", { status: 404 }); + return new Response("zip", { status: 200 }); + }; + + const platformMock = mock.method(os, "platform", () => "win32"); + const archMock = mock.method(os, "arch", () => "x64"); + try { + const installedPath = await mod.installVersion("1.0.0", tmpDir); + assert.equal(fs.readFileSync(installedPath, "utf8"), "installed-binary"); + assert.equal(fs.lstatSync(installedPath).isSymbolicLink(), false); + + const command = fs.readFileSync(commandLog, "utf8"); + assert.match(command, /Expand-Archive -LiteralPath/); + assert.doesNotMatch(command, /unzip/); + + const previousDir = path.join(binDir, "cliproxyapi-0.9.0"); + fs.mkdirSync(previousDir, { recursive: true }); + fs.writeFileSync(path.join(previousDir, "cli-proxy-api"), "rollback-binary"); + assert.equal(await mod.rollbackVersion(tmpDir), "0.9.0"); + assert.equal(fs.readFileSync(installedPath, "utf8"), "rollback-binary"); + assert.equal(fs.lstatSync(installedPath).isSymbolicLink(), false); + } finally { + platformMock.mock.restore(); + archMock.mock.restore(); + globalThis.fetch = originalFetch; + process.env.PATH = originalPath; + delete process.env.OMNI_TEST_COMMAND_LOG; + delete process.env.OMNI_TEST_EXTRACT_DIR; + } + }); + + it("writes the Windows rollback artifact at the CLIProxy spawn path", async () => { + const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + + try { + const binDir = path.join(tmpDir, "bin"); + for (const ver of ["1.0.0", "2.0.0"]) { + const versionDir = path.join(binDir, `cliproxyapi-${ver}`); + fs.mkdirSync(versionDir, { recursive: true }); + fs.writeFileSync(path.join(versionDir, "cli-proxy-api"), `bin-${ver}`); + } + + assert.equal(await mod.rollbackVersion(tmpDir), "1.0.0"); + const { resolveSpawnArgs } = await import("../../src/lib/services/installers/cliproxy.ts"); + const spawn = resolveSpawnArgs(8317); + + assert.equal(spawn.command, path.join(binDir, "cliproxyapi.exe")); + assert.equal(fs.existsSync(spawn.command), true); + assert.equal(await mod.getCurrentBinaryPath(tmpDir), spawn.command); + } finally { + if (originalPlatformDescriptor) { + Object.defineProperty(process, "platform", originalPlatformDescriptor); + } + } + }); + }); + + describe("downloadRelease platform parameter threading (#10244/#10293)", () => { + it("uses an explicitly-passed Windows target without reading os.platform() at all", async () => { + // Closing-fix regression guard: unlike the os.platform()/os.arch() mock-based + // tests above (which prove the single top-level detection reaches the right + // place, but would still pass even if extractZip re-read os.platform() itself + // since the mock is global), this test proves the actual PARAMETER THREADING: + // downloadRelease() is called with an explicit `target` and os.platform()/ + // os.arch() are NOT mocked at all — the real test host is Linux/darwin/etc. + // If downloadRelease or extractZip ever regressed to independently re-reading + // os.platform() instead of using the threaded `platform` value, this would + // resolve to the host's real (non-Windows) platform, `unzip` would run against + // a fake zip body, and the test would fail. + const binDir = path.join(tmpDir, "bin-param-thread"); + const extractedDir = path.join(binDir, "cliproxyapi-1.0.0"); + const fakePowerShellDir = path.join(tmpDir, "fake-powershell-param-thread"); + const commandLog = path.join(tmpDir, "powershell-command-param-thread.txt"); + const originalPath = process.env.PATH; + const originalFetch = globalThis.fetch; + + fs.mkdirSync(fakePowerShellDir, { recursive: true }); + fs.writeFileSync( + path.join(fakePowerShellDir, "powershell"), + "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$OMNI_TEST_COMMAND_LOG_PT\"\n" + + "mkdir -p \"$OMNI_TEST_EXTRACT_DIR_PT\"\nprintf 'installed-binary' > \"$OMNI_TEST_EXTRACT_DIR_PT/cli-proxy-api\"\n" + ); + fs.chmodSync(path.join(fakePowerShellDir, "powershell"), 0o755); + process.env.PATH = `${fakePowerShellDir}:${originalPath || ""}`; + process.env.OMNI_TEST_COMMAND_LOG_PT = commandLog; + process.env.OMNI_TEST_EXTRACT_DIR_PT = extractedDir; + + globalThis.fetch = async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("/releases/tags/")) { + return new Response( + JSON.stringify({ + tag_name: "v1.0.0", + published_at: "2026-01-01T00:00:00Z", + assets: [ + { + name: "CLIProxyAPI_1.0.0_windows_amd64.zip", + browser_download_url: "https://example.test/cliproxy.zip", + size: 3, + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + if (url.endsWith("checksums.txt")) return new Response("", { status: 404 }); + return new Response("zip", { status: 200 }); + }; + + try { + const binary = await mod.downloadRelease("1.0.0", binDir, undefined, { + platform: "windows", + arch: "amd64", + }); + assert.equal(fs.readFileSync(binary, "utf8"), "installed-binary"); + + const command = fs.readFileSync(commandLog, "utf8"); + assert.match(command, /Expand-Archive -LiteralPath/); + assert.doesNotMatch(command, /unzip/); + } finally { + globalThis.fetch = originalFetch; + process.env.PATH = originalPath; + delete process.env.OMNI_TEST_COMMAND_LOG_PT; + delete process.env.OMNI_TEST_EXTRACT_DIR_PT; + } + }); }); describe("removeVersion", () => { diff --git a/tests/unit/bug-10096-kimi-coding-apikey-save.test.ts b/tests/unit/bug-10096-kimi-coding-apikey-save.test.ts new file mode 100644 index 0000000000..54bfda78e7 --- /dev/null +++ b/tests/unit/bug-10096-kimi-coding-apikey-save.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Issue #10096: Kimi Code API key validates OK but Save returns 400 "Invalid provider". +// +// Root cause: the unified Kimi Code dashboard card's API-key branch posted +// provider: "kimi-coding" (an OAuth-primary managed id, NOT an admitted +// API-key connection id) to POST /api/providers, which the backend rejects. +// The dedicated managed API-key id "kimi-coding-apikey" IS admitted. +// +// Fix: resolveApiKeySaveProviderId() in useApiKeySave.ts remaps the posted +// provider id to "kimi-coding-apikey" for the API-key save flow only, while +// the OAuth flow (which never calls this hook) keeps posting "kimi-coding". + +const { isManagedProviderConnectionId } = await import("../../src/lib/providers/catalog.ts"); +const { resolveApiKeySaveProviderId } = await import( + "../../src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts" +); + +test("Kimi Code API-key save flow remaps to the admitted managed API-key id", () => { + assert.equal( + resolveApiKeySaveProviderId("kimi-coding"), + "kimi-coding-apikey", + "the unified Kimi Code card's API-key save flow must post kimi-coding-apikey, not kimi-coding" + ); + assert.equal( + isManagedProviderConnectionId(resolveApiKeySaveProviderId("kimi-coding")), + true, + "the remapped id must be an admitted managed provider connection id (POST /api/providers accepts it)" + ); +}); + +test("resolveApiKeySaveProviderId leaves every other provider id untouched", () => { + assert.equal(resolveApiKeySaveProviderId("openai"), "openai"); + assert.equal(resolveApiKeySaveProviderId("kimi-coding-apikey"), "kimi-coding-apikey"); + assert.equal(resolveApiKeySaveProviderId("qoder"), "qoder"); +}); diff --git a/tests/unit/bug-10183-admission-heavy-healthy-heap.test.ts b/tests/unit/bug-10183-admission-heavy-healthy-heap.test.ts new file mode 100644 index 0000000000..33a475c0df --- /dev/null +++ b/tests/unit/bug-10183-admission-heavy-healthy-heap.test.ts @@ -0,0 +1,66 @@ +// #10183: regression 3.8.48 → 3.8.49 — chat admission rejected a second concurrent +// "heavy" request even on a healthy heap. `admitChatStructure`'s CHAT_MAX_HEAVY_IN_FLIGHT=1 +// cap (#9654/#9940) sheds unconditionally once busy; this test proves shedding must be +// gated on real heap pressure (restoring 3.8.48's `heapUsed/heapLimit >= shedRatio` +// semantics) instead of firing regardless of free memory. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ChatAdmissionController, + admitChatStructure, +} from "../../src/shared/middleware/chatBodyAdmission.ts"; + +function heavyBody() { + return { + messages: Array.from({ length: 200 }, () => ({ + role: "user", + content: "x".repeat(400), + })), + tools: [] as unknown[], + }; +} + +test("bug-10183: second concurrent heavy request admitted on a healthy heap", async () => { + const controller = new ChatAdmissionController(1); // default CHAT_MAX_HEAVY_IN_FLIGHT=1 + const first = await admitChatStructure(heavyBody(), null, { controller }); + assert.equal(first.admit, true); + assert.ok(first.admit && first.lease, "first heavy request should hold the lease"); + + try { + const second = await admitChatStructure(heavyBody(), null, { + controller, + queueMs: 50, + // No override: default heap probe reads live process stats, which are + // healthy in the test process — proves the fix without mocking away the + // real check. + }); + assert.equal(second.admit, true, "healthy heap must not shed a 2nd heavy request"); + if (second.admit) second.lease?.release(); + } finally { + if (first.admit) first.lease?.release(); + } +}); + +test("bug-10183: a genuinely pressured heap still sheds the 2nd heavy request", async () => { + const controller = new ChatAdmissionController(1); + const first = await admitChatStructure(heavyBody(), null, { controller }); + assert.equal(first.admit, true); + assert.ok(first.admit && first.lease); + + try { + const second = await admitChatStructure(heavyBody(), null, { + controller, + queueMs: 0, + heapPressureCheck: () => true, // simulate real heap pressure + }); + assert.equal(second.admit, false, "real heap pressure must still shed the 2nd request"); + if (!second.admit) { + assert.equal(second.response.status, 503); + const payload = await second.response.json(); + assert.equal(payload.error.code, "chat_admission_busy"); + assert.equal(payload.error.reason, "structure_limit"); + } + } finally { + if (first.admit) first.lease?.release(); + } +}); diff --git a/tests/unit/call-log-artifact-worker.test.ts b/tests/unit/call-log-artifact-worker.test.ts new file mode 100644 index 0000000000..948a3fd110 --- /dev/null +++ b/tests/unit/call-log-artifact-worker.test.ts @@ -0,0 +1,168 @@ +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-call-log-worker-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { writeCallArtifactAsync, closeCallLogArtifactWriter, resolveCallLogArtifactWorker } = + await import("../../src/lib/usage/callLogArtifactWriter.ts"); + +test.after(async () => { + await closeCallLogArtifactWriter(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function buildArtifact(id: string) { + return { + schemaVersion: 5 as const, + summary: { + id, + timestamp: "2026-08-11T12:34:56.789Z", + method: "POST", + path: "/v1/chat/completions", + status: 200, + model: "test-model", + requestedModel: null, + provider: "test-provider", + account: "test-account", + connectionId: null, + duration: 10, + tokens: { + in: 1, + out: 2, + cacheRead: null, + cacheWrite: null, + reasoning: null, + compressed: null, + }, + requestType: "chat", + sourceFormat: "openai", + targetFormat: "openai", + apiKeyId: null, + apiKeyName: null, + comboName: null, + comboStepId: null, + comboExecutionKey: null, + }, + requestBody: { worker: true }, + responseBody: { content: "written" }, + error: null, + }; +} + +test("worker resolution covers npm, standalone, source, and missing layouts", () => { + const layoutRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-worker-layout-")); + const createWorker = (workerFile: string) => { + fs.mkdirSync(path.dirname(workerFile), { recursive: true }); + fs.writeFileSync(workerFile, ""); + }; + + try { + const npmRoot = path.join(layoutRoot, "package", "dist"); + const npmWorker = path.join(npmRoot, "src", "lib", "usage", "callLogArtifactWorker.js"); + createWorker(npmWorker); + assert.deepEqual( + resolveCallLogArtifactWorker({ + moduleDir: path.join(npmRoot, ".next", "server", "chunks"), + cwd: path.join(layoutRoot, "unrelated-caller"), + entryFile: path.join(npmRoot, "server.js"), + fileExists: fs.existsSync, + }), + { workerFile: npmWorker, execArgv: [] } + ); + + const standaloneRoot = path.join(layoutRoot, "standalone"); + const standaloneWorker = path.join( + standaloneRoot, + "src", + "lib", + "usage", + "callLogArtifactWorker.js" + ); + createWorker(standaloneWorker); + assert.deepEqual( + resolveCallLogArtifactWorker({ + moduleDir: path.join(standaloneRoot, ".next", "server", "chunks"), + cwd: standaloneRoot, + entryFile: null, + fileExists: fs.existsSync, + }), + { workerFile: standaloneWorker, execArgv: [] } + ); + + const sourceDir = path.join(layoutRoot, "source", "src", "lib", "usage"); + const sourceWorker = path.join(sourceDir, "callLogArtifactWorker.ts"); + createWorker(sourceWorker); + assert.deepEqual( + resolveCallLogArtifactWorker({ + moduleDir: sourceDir, + cwd: path.join(layoutRoot, "unrelated-source-caller"), + entryFile: null, + fileExists: fs.existsSync, + }), + { workerFile: sourceWorker, execArgv: ["--import", "tsx/esm"] } + ); + + const missingRoot = path.join(layoutRoot, "missing"); + assert.deepEqual( + resolveCallLogArtifactWorker({ + moduleDir: path.join(missingRoot, ".next", "server", "chunks"), + cwd: path.join(layoutRoot, "unrelated-missing-caller"), + entryFile: path.join(missingRoot, "server.js"), + fileExists: fs.existsSync, + }), + { + workerFile: path.join(missingRoot, "src", "lib", "usage", "callLogArtifactWorker.js"), + execArgv: [], + } + ); + } finally { + fs.rmSync(layoutRoot, { recursive: true, force: true }); + } + + const resolved = resolveCallLogArtifactWorker(); + assert.equal(fs.existsSync(resolved.workerFile), true); + assert.equal(path.basename(resolved.workerFile), "callLogArtifactWorker.ts"); + assert.deepEqual(resolved.execArgv, ["--import", "tsx/esm"]); + + const source = fs.readFileSync("src/lib/usage/callLogArtifactWriter.ts", "utf8"); + assert.doesNotMatch(source, /firstAncestorWith|MAX_WALK_UP|runtimeAnchors/); + assert.doesNotMatch(source, /new Worker\(/); + assert.match(source, /Reflect\.construct\(Worker/); +}); + +test("async worker writes call-log artifact and returns matching metadata", async () => { + const artifact = buildArtifact("worker-write-1"); + const result = await writeCallArtifactAsync(artifact); + assert.ok(result); + + const artifactPath = path.join(TEST_DATA_DIR, "call_logs", result.relPath); + const serialized = fs.readFileSync(artifactPath, "utf8"); + assert.equal(result.sizeBytes, Buffer.byteLength(serialized)); + assert.match(result.sha256, /^[0-9a-f]{8}$/); + assert.deepEqual(JSON.parse(serialized), artifact); +}); + +test("bounded queue fails open and rate-limits saturation warnings", async () => { + const originalWarn = console.warn; + let warningCount = 0; + console.warn = () => { + warningCount++; + }; + + try { + const writes = Array.from({ length: 131 }, (_, index) => + writeCallArtifactAsync(buildArtifact(`worker-overflow-${index}`)) + ); + assert.equal(warningCount, 1); + + await closeCallLogArtifactWriter(0); + const results = await Promise.all(writes); + assert.ok(results.every((result) => result === null)); + } finally { + console.warn = originalWarn; + } +}); diff --git a/tests/unit/call-log-cap.test.ts b/tests/unit/call-log-cap.test.ts index f1e63a6474..e1cbd7e958 100644 --- a/tests/unit/call-log-cap.test.ts +++ b/tests/unit/call-log-cap.test.ts @@ -166,7 +166,10 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art assert.equal(typeof (summaryRow as any).artifact_relpath, "string"); const artifactPath = path.join(TEST_DATA_DIR, "call_logs", detail.artifactRelPath); - const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8")); + const serializedArtifact = fs.readFileSync(artifactPath, "utf8"); + const artifact = JSON.parse(serializedArtifact); + assert.equal(Buffer.byteLength(serializedArtifact), detail.artifactSizeBytes); + assert.match(detail.artifactSha256 || "", /^[0-9a-f]{8}$/); assert.equal(artifact.summary.id, logId); assert.equal(artifact.summary.requestedModel, "openai/gpt-5"); assert.equal(artifact.summary.comboExecutionKey, "combo-a:0:step-openai-a"); diff --git a/tests/unit/call-log-save-drain.test.ts b/tests/unit/call-log-save-drain.test.ts new file mode 100644 index 0000000000..6371d2785d --- /dev/null +++ b/tests/unit/call-log-save-drain.test.ts @@ -0,0 +1,104 @@ +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 { useDecollidedMigrationsDir } from "./helpers/decollidedMigrationsDir.ts"; + +useDecollidedMigrationsDir(); +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-call-log-drain-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const callLogs = await import("../../src/lib/usage/callLogs.ts"); +const artifactWriter = await import("../../src/lib/usage/callLogArtifactWriter.ts"); + +test.after(async () => { + await artifactWriter.closeCallLogArtifactWriter(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("call-log drain waits for artifact metadata and summary commit", async () => { + const id = "drain-write-1"; + void callLogs.saveCallLog({ + id, + timestamp: "2026-08-11T12:34:56.789Z", + status: 200, + model: "test-model", + provider: "test-provider", + requestBody: { pending: true }, + responseBody: { committed: true }, + }); + + // The first cold spawn of the worker_threads artifact worker (loaded via tsx) + // can take ~2.4s on its own before queued artifact writes even start draining, + // so a 2s wait is flaky on cold runs. 10s is generous headroom while still + // failing fast on a genuinely stuck drain. + assert.equal(await callLogs.waitForCallLogSaves(10_000), true); + + const row = core + .getDbInstance() + .prepare( + `SELECT detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256 + FROM call_logs WHERE id = ?` + ) + .get(id) as { + detail_state: string; + artifact_relpath: string | null; + artifact_size_bytes: number | null; + artifact_sha256: string | null; + }; + + assert.equal(row.detail_state, "ready"); + assert.ok(row.artifact_relpath); + assert.ok(row.artifact_size_bytes && row.artifact_size_bytes > 0); + assert.match(row.artifact_sha256 || "", /^[0-9a-f]{8}$/); + assert.equal(fs.existsSync(path.join(TEST_DATA_DIR, "call_logs", row.artifact_relpath)), true); +}); + +test("forced close settles tracked saves before rejecting late saves", async () => { + const pendingId = "drain-forced-close"; + const pending = callLogs.saveCallLog({ + id: pendingId, + timestamp: "2026-08-11T12:35:56.789Z", + status: 200, + model: "test-model", + provider: "test-provider", + requestBody: { pending: true }, + }); + + await callLogs.closeCallLogSaves(0); + await pending; + + const row = core + .getDbInstance() + .prepare( + `SELECT detail_state, artifact_relpath, artifact_size_bytes, artifact_sha256 + FROM call_logs WHERE id = ?` + ) + .get(pendingId) as { + detail_state: string; + artifact_relpath: string | null; + artifact_size_bytes: number | null; + artifact_sha256: string | null; + }; + assert.equal(row.detail_state, "missing"); + assert.equal(row.artifact_relpath, null); + assert.equal(row.artifact_size_bytes, null); + assert.equal(row.artifact_sha256, null); + + await callLogs.saveCallLog({ + id: "drain-late-save", + timestamp: "2026-08-11T12:36:56.789Z", + status: 200, + model: "test-model", + provider: "test-provider", + }); + const lateCount = core + .getDbInstance() + .prepare("SELECT COUNT(*) AS count FROM call_logs WHERE id = ?") + .get("drain-late-save") as { count: number }; + assert.equal(lateCount.count, 0); +}); diff --git a/tests/unit/chat-admission-healthy-headroom-10437.test.ts b/tests/unit/chat-admission-healthy-headroom-10437.test.ts new file mode 100644 index 0000000000..f668e46759 --- /dev/null +++ b/tests/unit/chat-admission-healthy-headroom-10437.test.ts @@ -0,0 +1,117 @@ +// #10437: the #10183/#10268 fix admitted a busy heavyweight request immediately +// whenever the heap was healthy, via an unconditional no-op lease — with no bound +// of its own. That let an UNLIMITED number of "healthy heap" requests pile in ahead +// of the heap-pressure shed path, defeating the purpose of admission control: a +// slow leak (or a burst that never quite trips the heap-pressure ratio) could still +// starve the process. This is the permanent regression guard proving the +// healthy-heap fast path now has a real, finite ceiling (`healthyHeadroom`) and +// falls through to the SAME bounded-wait/shed path used under real heap pressure +// once that budget is exhausted — the existing #10183/#10268 heap-pressure gate is +// preserved unchanged; only the previously-unbounded healthy path is now bounded. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ChatAdmissionController, + admitChatStructure, +} from "../../src/shared/middleware/chatBodyAdmission.ts"; + +function heavyBody() { + return { + messages: Array.from({ length: 200 }, () => ({ role: "user", content: "x".repeat(40) })), + tools: [] as unknown[], + }; +} + +const heapHealthy = () => false; // "not under pressure" — the healthy-heap fast path + +test("#10437: the healthy-heap fast path admits only a bounded headroom budget, never unlimited requests", async () => { + const HEALTHY_HEADROOM = 2; + // maxHeavyInFlight=1 (the primary structural lease); healthyHeadroom=2 is the + // ADDITIONAL bounded budget available only while the heap stays healthy. + const controller = new ChatAdmissionController(1, undefined, HEALTHY_HEADROOM); + + // Occupy the single primary lease directly, simulating one in-flight heavy + // request — every subsequent admission below must go through the healthy-heap + // fast path (busy primary capacity + healthy heap). + const primary = controller.tryAcquireHeavy(); + assert.ok(primary); + + // Fire 3 CONCURRENT structurally-heavy requests on a healthy heap while the + // primary lease is busy. Pre-fix, `admitChatStructure` returned a fresh no-op + // lease for every single one of them, unconditionally — no ceiling existed. + // Post-fix, only HEALTHY_HEADROOM (2) may bypass through the bounded headroom + // budget; the remaining request must fall through to the bounded-wait/shed + // path (queueMs=0 → immediate retryable 503), exactly like real heap pressure. + const results = await Promise.all( + Array.from({ length: 3 }, () => + admitChatStructure(heavyBody(), null, { + controller, + heapPressureCheck: heapHealthy, + queueMs: 0, + }) + ) + ); + + const admitted = results.filter((r) => r.admit); + const rejected = results.filter((r) => !r.admit); + + assert.equal( + admitted.length, + HEALTHY_HEADROOM, + "only the finite healthy-headroom budget may bypass a busy primary lease on a healthy heap" + ); + assert.equal( + rejected.length, + 3 - HEALTHY_HEADROOM, + "once the headroom budget is exhausted, further healthy-heap requests must be shed, not silently admitted" + ); + for (const r of rejected) { + if (r.admit) continue; + assert.equal(r.response.status, 503); + const payload = await r.response.json(); + assert.equal(payload.error.code, "chat_admission_busy"); + assert.equal(payload.error.reason, "structure_limit"); + } + + assert.equal( + controller.activeHealthyHeadroom, + HEALTHY_HEADROOM, + "the headroom budget tracks its own active count independently of the primary lease" + ); + + primary.release(); + for (const r of admitted) if (r.admit) r.lease?.release(); + assert.equal(controller.activeHealthyHeadroom, 0, "released headroom leases free the budget"); +}); + +test("#10437: healthyHeadroom=0 disables the fast-path bypass entirely — every busy healthy-heap request is bounded by the shed path", async () => { + const controller = new ChatAdmissionController(1, undefined, 0); + const primary = controller.tryAcquireHeavy(); + assert.ok(primary); + + const result = await admitChatStructure(heavyBody(), null, { + controller, + heapPressureCheck: heapHealthy, + queueMs: 0, + }); + + assert.equal(result.admit, false, "with a zero headroom budget, a busy healthy-heap request must be shed"); + if (!result.admit) assert.equal(result.response.status, 503); + primary.release(); +}); + +test("#10437: the healthy-heap headroom budget still lets legitimate agent fan-out through up to its bound", async () => { + // Default headroom (>= 1) must still admit at least one bypass, matching the + // #10183/#10268 fix's original intent — this is not a regression to always-shed. + const controller = new ChatAdmissionController(1); + const primary = controller.tryAcquireHeavy(); + assert.ok(primary); + + const result = await admitChatStructure(heavyBody(), null, { + controller, + heapPressureCheck: heapHealthy, + }); + assert.equal(result.admit, true, "at least the default headroom budget must admit a healthy-heap request"); + if (result.admit) result.lease?.release(); + primary.release(); +}); diff --git a/tests/unit/chat-body-admission-queue.test.ts b/tests/unit/chat-body-admission-queue.test.ts index caaa36c8e0..345954c142 100644 --- a/tests/unit/chat-body-admission-queue.test.ts +++ b/tests/unit/chat-body-admission-queue.test.ts @@ -43,6 +43,9 @@ test("a heavy structural request waits for capacity instead of failing immediate heavyTools: 10, heavyTokens: 10_000, queueMs: 500, + // #10183/#10268: entry into the bounded-wait path requires real heap + // pressure now; force it so this test still exercises the wait. + heapPressureCheck: () => true, } ); @@ -85,6 +88,9 @@ test("waiting for admission times out into a retryable 503", async () => { heavyTools: 10, heavyTokens: 10_000, queueMs: 50, + // #10183/#10268: entry into the bounded-wait/shed path requires real + // heap pressure now; force it to still exercise the timeout. + heapPressureCheck: () => true, } ); @@ -148,6 +154,9 @@ test("expired admission queue keeps the legacy immediate 503 behaviour", async ( heavyTools: 10, heavyTokens: 10_000, queueMs: 0, + // #10183/#10268: shedding now requires real heap pressure; force it to + // still exercise the legacy immediate-reject path. + heapPressureCheck: () => true, } ); @@ -174,6 +183,9 @@ test("admission waiters are served FIFO as capacity frees", async () => { heavyTools: 10, heavyTokens: 10_000, queueMs: 500, + // #10183/#10268: entry into the bounded-wait path requires real heap + // pressure now; force it so both waiters still queue. + heapPressureCheck: () => true, }; const first = admitChatStructure(body, null, options); const second = admitChatStructure(body, null, options); @@ -367,6 +379,9 @@ test("structural admission enforces the queued-bytes cap end-to-end", async () = heavyTools: 10, heavyTokens: 10_000, queueMs: 2_000, + // #10183/#10268: entry into the bounded-wait path requires real heap + // pressure now; force it so the queued-bytes cap is still exercised. + heapPressureCheck: () => true, }; // First structural wait parks, charging the conservative 256KB weight. @@ -485,6 +500,9 @@ test("aborting the signal cancels a structural queue-wait", async () => { heavyTokens: 10_000, queueMs: 2_000, signal: abortController.signal, + // #10183/#10268: entry into the bounded-wait path requires real heap + // pressure now; force it so the abort is still exercised mid-wait. + heapPressureCheck: () => true, } ); diff --git a/tests/unit/chat-body-admission.test.ts b/tests/unit/chat-body-admission.test.ts index 8544bc50c0..a4e0503db8 100644 --- a/tests/unit/chat-body-admission.test.ts +++ b/tests/unit/chat-body-admission.test.ts @@ -80,7 +80,7 @@ test("a byte-light request above the message threshold acquires heavyweight capa assert.equal(controller.activeHeavy, 0); }); -test("a byte-light request above the tool threshold is rejected when heavy capacity is busy", async () => { +test("a byte-light request above the tool threshold is rejected when heavy capacity is busy AND the heap is genuinely under pressure (#10183/#10268)", async () => { const controller = new ChatAdmissionController(1); const occupied = controller.tryAcquireHeavy(); assert.ok(occupied); @@ -88,7 +88,16 @@ test("a byte-light request above the tool threshold is rejected when heavy capac const result = await admitChatStructure( { messages: [], tools: [{ type: "function" }, { type: "function" }] }, null, - { controller, maxMessages: 10, heavyMessages: 10, heavyTools: 2, heavyTokens: 10_000 } + { + controller, + maxMessages: 10, + heavyMessages: 10, + heavyTools: 2, + heavyTokens: 10_000, + // #10183/#10268: shedding is now conditional on real heap pressure, not + // capacity alone — simulate the pressured case this test targets. + heapPressureCheck: () => true, + } ); assert.equal(result.admit, false); @@ -135,7 +144,7 @@ test("no history cap is enforced by default; long conversations are admitted", a result.lease?.release(); }); -test("an uncapped oversized conversation still yields to occupied heavyweight capacity", async () => { +test("an uncapped oversized conversation still yields to occupied heavyweight capacity when the heap is genuinely under pressure (#10183/#10268)", async () => { const controller = new ChatAdmissionController(1); const occupied = controller.tryAcquireHeavy(); assert.ok(occupied); @@ -143,7 +152,15 @@ test("an uncapped oversized conversation still yields to occupied heavyweight ca const result = await admitChatStructure( { messages: Array.from({ length: 5_000 }, () => ({ role: "user", content: "x" })) }, null, - { controller, maxMessages: 0, heavyMessages: 200, heavyTools: 64, heavyTokens: 32_000 } + { + controller, + maxMessages: 0, + heavyMessages: 200, + heavyTools: 64, + heavyTokens: 32_000, + // #10183/#10268: shedding is now conditional on real heap pressure. + heapPressureCheck: () => true, + } ); assert.equal(result.admit, false); diff --git a/tests/unit/chat-managed-lease-routing.test.ts b/tests/unit/chat-managed-lease-routing.test.ts new file mode 100644 index 0000000000..2511d89fff --- /dev/null +++ b/tests/unit/chat-managed-lease-routing.test.ts @@ -0,0 +1,635 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +const harness = await createChatPipelineHarness("chat-managed-lease-routing"); +const { + apiKeysDb, + buildOpenAIResponse, + buildRequest, + combosDb, + handleChat, + resetStorage, + seedConnection, +} = harness; +const leaseDb = await import("../../src/lib/db/exclusiveConnectionLeases.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const accountSemaphores = await import("../../open-sse/services/accountSemaphore.ts"); +const { POST: handleCompletions } = await import("../../src/app/api/v1/completions/route.ts"); + +const OWNER = "vlo_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; +const OWNER_B = "vlo_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; + +async function seedManagedKey(connectionIds: string[]) { + return apiKeysDb.createApiKey("managed-chat", "test", ["lease:exclusive"], { + allowedConnections: connectionIds, + }); +} + +function managedRequest( + key: string, + generation: number, + extraHeaders = {}, + bodyOverrides: Record = {}, + owner = OWNER, + url = "http://localhost/v1/chat/completions" +) { + return buildRequest({ + url, + authKey: key, + headers: { + "X-OmniRoute-Lease-Owner": owner, + "X-OmniRoute-Lease-Generation": String(generation), + ...extraHeaders, + }, + body: { + model: "openai/gpt-4.1", + stream: false, + messages: [{ role: "user", content: "synthetic managed lease test" }], + ...bodyOverrides, + }, + }); +} + +function buildOpenAIStreamResponse(text: string): Response { + const frames = [ + `data: ${JSON.stringify({ + id: "chatcmpl_stream", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl_stream", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + })}\n\n`, + "data: [DONE]\n\n", + ]; + return new Response(frames.join(""), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }); +} + +test.beforeEach(async () => { + process.env.REQUIRE_API_KEY = "false"; + await resetStorage(); +}); +test.after(async () => harness.cleanup()); + +test("managed chat requires explicit owner and generation before provider dispatch", async () => { + const connection = await seedConnection("openai"); + const key = await seedManagedKey([connection.id]); + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + throw new Error("unexpected provider dispatch"); + }; + + const missingOwner = await handleChat( + buildRequest({ + authKey: key.key, + body: { + model: "openai/gpt-4.1", + stream: false, + messages: [{ role: "user", content: "missing owner" }], + }, + }) + ); + assert.equal(missingOwner.status, 400); + assert.equal((await missingOwner.json()).error.code, "LEASE_CONTEXT_REQUIRED"); + + const missingGeneration = await handleChat( + buildRequest({ + authKey: key.key, + headers: { "X-OmniRoute-Lease-Owner": OWNER }, + body: { + model: "openai/gpt-4.1", + stream: false, + messages: [{ role: "user", content: "missing generation" }], + }, + }) + ); + assert.equal(missingGeneration.status, 400); + assert.equal((await missingGeneration.json()).error.code, "LEASE_CONTEXT_INVALID"); + assert.equal(dispatches, 0); +}); + +test("managed chat blocks missing and stale leases with zero provider dispatch", async () => { + const connection = await seedConnection("openai"); + const key = await seedManagedKey([connection.id]); + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + throw new Error("unexpected provider dispatch"); + }; + + const missing = await handleChat(managedRequest(key.key, 1)); + assert.equal(missing.status, 409); + assert.equal((await missing.json()).error.code, "LEASE_REQUIRED"); + + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + + const stale = await handleChat(managedRequest(key.key, acquired.lease.generation + 1)); + assert.equal(stale.status, 409); + assert.equal((await stale.json()).error.code, "LEASE_FENCE_STALE"); + assert.equal(dispatches, 0); +}); + +test("managed chat blocks cross-key owner-generation replay before provider dispatch", async () => { + const connection = await seedConnection("openai"); + const ownerKey = await seedManagedKey([connection.id]); + const replayKey = await seedManagedKey([connection.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: ownerKey.id, + provider: "openai", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + throw new Error("unexpected provider dispatch"); + }; + const replay = await handleChat(managedRequest(replayKey.key, acquired.lease.generation)); + + assert.equal(replay.status, 409); + assert.equal((await replay.json()).error.code, "LEASE_FENCE_STALE"); + assert.equal(dispatches, 0); + assert.equal(leaseDb.getActiveExclusiveConnectionLease(OWNER)?.apiKeyId, ownerKey.id); +}); + +test("managed chat dispatches only the fenced active binding", async () => { + const connection = await seedConnection("openai"); + const key = await seedManagedKey([connection.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + return buildOpenAIResponse("managed success"); + }; + const response = await handleChat(managedRequest(key.key, acquired.lease.generation)); + assert.equal(response.status, 200); + assert.equal((await response.json()).choices[0].message.content, "managed success"); + assert.equal(dispatches, 1); +}); + +test("identical prompts with different owners never share a managed connection", async () => { + const firstConnection = await seedConnection("openai", { + name: "managed-owner-a", + apiKey: "sk-managed-owner-a", + priority: 1, + }); + const secondConnection = await seedConnection("openai", { + name: "managed-owner-b", + apiKey: "sk-managed-owner-b", + priority: 2, + }); + const key = await seedManagedKey([firstConnection.id, secondConnection.id]); + const firstLease = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: firstConnection.id, + }); + const secondLease = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER_B, + apiKeyId: key.id, + provider: "openai", + connectionId: secondConnection.id, + }); + assert.equal(firstLease.kind, "ACQUIRED"); + assert.equal(secondLease.kind, "ACQUIRED"); + if (firstLease.kind !== "ACQUIRED" || secondLease.kind !== "ACQUIRED") return; + + const usedApiKeys: string[] = []; + globalThis.fetch = async (_url, init) => { + usedApiKeys.push(new Headers(init?.headers).get("authorization") ?? ""); + return buildOpenAIResponse("isolated owner success"); + }; + const sharedBody = { + model: "openai/gpt-4.1", + stream: false, + messages: [{ role: "user", content: "byte-identical prompt and tools" }], + tools: [{ type: "function", function: { name: "noop", parameters: { type: "object" } } }], + }; + const firstResponse = await handleChat( + managedRequest( + key.key, + firstLease.lease.generation, + { "X-Session-Id": "same-routing-session" }, + sharedBody, + OWNER + ) + ); + const secondResponse = await handleChat( + managedRequest( + key.key, + secondLease.lease.generation, + { "X-Session-Id": "same-routing-session" }, + sharedBody, + OWNER_B + ) + ); + + assert.equal(firstResponse.status, 200); + assert.equal(secondResponse.status, 200); + assert.equal(usedApiKeys.length, 2); + assert.notEqual(usedApiKeys[0], usedApiKeys[1]); + assert.notEqual( + leaseDb.getActiveExclusiveConnectionLease(OWNER)?.connectionId, + leaseDb.getActiveExclusiveConnectionLease(OWNER_B)?.connectionId + ); +}); + +test("changing prompt, tools, and request model does not change the owner binding", async () => { + const connection = await seedConnection("openai"); + const key = await seedManagedKey([connection.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + return buildOpenAIResponse("identity stable"); + }; + for (const body of [ + { messages: [{ role: "user", content: "prompt one" }] }, + { + messages: [{ role: "user", content: "prompt two" }], + tools: [{ type: "function", function: { name: "other", parameters: { type: "object" } } }], + }, + { model: "openai/gpt-4o-mini", messages: [{ role: "user", content: "model changed" }] }, + ]) { + const response = await handleChat(managedRequest(key.key, acquired.lease.generation, {}, body)); + assert.equal(response.status, 200); + assert.equal(leaseDb.getActiveExclusiveConnectionLease(OWNER)?.connectionId, connection.id); + } + assert.equal(dispatches, 3); +}); + +test("legacy completions and messages-compatible paths use the managed lease handler", async () => { + const connection = await seedConnection("openai"); + const key = await seedManagedKey([connection.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + return buildOpenAIResponse("legacy fenced"); + }; + const completions = await handleCompletions( + managedRequest( + key.key, + acquired.lease.generation, + {}, + { prompt: "legacy completion", messages: undefined }, + OWNER, + "http://localhost/v1/completions" + ) + ); + const messages = await handleChat( + managedRequest( + key.key, + acquired.lease.generation, + {}, + { messages: [{ role: "user", content: "messages compatible" }] }, + OWNER, + "http://localhost/v1/messages" + ) + ); + + assert.equal(completions.status, 200); + assert.equal(messages.status, 200); + assert.equal(dispatches, 2); + assert.equal(leaseDb.getActiveExclusiveConnectionLease(OWNER)?.connectionId, connection.id); +}); + +test("managed chat fences after an admission wait and before main executor dispatch", async () => { + const connection = await seedConnection("openai"); + await providersDb.updateProviderConnection(connection.id, { maxConcurrent: 1 }); + const key = await seedManagedKey([connection.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + + const semaphoreKey = accountSemaphores.buildAccountSemaphoreKey({ + provider: "openai", + accountKey: connection.id, + }); + const releaseBlocker = await accountSemaphores.acquire(semaphoreKey, { maxConcurrency: 1 }); + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + throw new Error("unexpected provider dispatch after stale fence"); + }; + + const pending = handleChat(managedRequest(key.key, acquired.lease.generation)); + for (let i = 0; i < 40; i += 1) { + if ((accountSemaphores.getStats()[semaphoreKey]?.queued ?? 0) === 1) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.equal(accountSemaphores.getStats()[semaphoreKey]?.queued, 1); + + assert.equal( + leaseDb.releaseExclusiveConnectionLease({ + leaseOwnerId: OWNER, + generation: acquired.lease.generation, + apiKeyId: key.id, + }).kind, + "RELEASED" + ); + releaseBlocker(); + const response = await pending; + + assert.equal(response.status, 409); + assert.equal((await response.json()).error.code, "LEASE_REQUIRED"); + assert.equal(dispatches, 0); +}); + +test("managed streaming chat preserves the lifecycle lease after completion", async () => { + const connection = await seedConnection("openai"); + const key = await seedManagedKey([connection.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + return buildOpenAIStreamResponse("managed stream success"); + }; + const response = await handleChat( + managedRequest( + key.key, + acquired.lease.generation, + { Accept: "text/event-stream" }, + { + stream: true, + } + ) + ); + const body = await response.text(); + + assert.equal(response.status, 200); + assert.match(response.headers.get("Content-Type") || "", /text\/event-stream/); + assert.match(body, /managed stream success/); + assert.equal(dispatches, 1); + assert.equal( + leaseDb.getActiveExclusiveConnectionLease(OWNER)?.generation, + acquired.lease.generation + ); +}); + +test("managed Responses-shaped request uses the same fenced chat path", async () => { + const connection = await seedConnection("openai"); + const key = await seedManagedKey([connection.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + return buildOpenAIResponse("responses fenced success"); + }; + const response = await handleChat( + buildRequest({ + url: "http://localhost/v1/responses", + authKey: key.key, + headers: { + "X-OmniRoute-Lease-Owner": OWNER, + "X-OmniRoute-Lease-Generation": String(acquired.lease.generation), + }, + body: { + model: "openai/gpt-4.1", + stream: false, + input: "synthetic Responses request", + }, + }) + ); + + assert.equal(response.status, 200); + assert.equal(dispatches, 1); + assert.equal(leaseDb.getActiveExclusiveConnectionLease(OWNER)?.connectionId, connection.id); +}); + +test("a direct foreign connection pin cannot override the active binding", async () => { + const bound = await seedConnection("openai", { name: "managed-bound", priority: 1 }); + const other = await seedConnection("openai", { name: "managed-other", priority: 2 }); + const key = await seedManagedKey([bound.id, other.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: bound.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + throw new Error("unexpected provider dispatch"); + }; + + assert.notEqual(bound.id, other.id); + assert.equal(leaseDb.getActiveExclusiveConnectionLease(OWNER)?.connectionId, bound.id); + + const pinnedRequest = managedRequest(key.key, acquired.lease.generation, { + "X-OmniRoute-Connection": other.id, + }); + assert.equal(pinnedRequest.headers.get("x-omniroute-connection"), other.id); + const response = await handleChat(pinnedRequest); + assert.equal(response.status, 409); + assert.equal((await response.json()).error.code, "LEASE_CONNECTION_MISMATCH"); + assert.equal(dispatches, 0); + assert.equal((await providersDb.getProviderConnectionById(bound.id))?.testStatus, "active"); +}); + +test("managed chat retains ordinary cooldown semantics instead of reporting lease capacity", async () => { + const connection = await seedConnection("openai", { + rateLimitedUntil: new Date(Date.now() + 60_000).toISOString(), + }); + const key = await seedManagedKey([connection.id]); + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + throw new Error("unexpected provider dispatch"); + }; + + const response = await handleChat(managedRequest(key.key, 1)); + assert.notEqual(response.status, 429); + assert.notEqual((await response.json()).state, "WAITING_FOR_CAPACITY"); + assert.equal(dispatches, 0); +}); + +test("empty ordinary eligibility is not reported as lease capacity contention", async () => { + const connection = await seedConnection("openai"); + const key = await seedManagedKey([connection.id]); + await providersDb.updateProviderConnection(connection.id, { testStatus: "banned" }); + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + throw new Error("unexpected provider dispatch"); + }; + + const response = await handleChat(managedRequest(key.key, 1)); + const body = await response.json(); + assert.notEqual(body.state, "WAITING_FOR_CAPACITY"); + assert.notEqual(body.error?.code, "LEASE_CAPACITY_UNAVAILABLE"); + assert.equal(dispatches, 0); +}); + +test("managed combos reject every fan-out route before provider dispatch", async () => { + const firstConnection = await seedConnection("openai", { + name: "managed-combo-first", + apiKey: "sk-managed-combo-first", + }); + const secondConnection = await seedConnection("openai", { + name: "managed-combo-second", + apiKey: "sk-managed-combo-second", + }); + const key = await seedManagedKey([firstConnection.id, secondConnection.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: firstConnection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + + const cases = [ + { name: "managed-fusion", strategy: "fusion", models: ["openai/gpt-4.1"] }, + { name: "managed-relay", strategy: "context-relay", models: ["openai/gpt-4.1"] }, + { + name: "managed-pipeline-multi", + strategy: "pipeline", + models: ["openai/gpt-4.1", "openai/gpt-4o-mini"], + }, + { + name: "managed-chaos", + strategy: "priority", + config: { chaos: { enabled: true } }, + models: ["openai/gpt-4.1", "openai/gpt-4o-mini"], + }, + { + name: "managed-shadow", + strategy: "priority", + config: { shadowRouting: { enabled: true, targets: ["openai/gpt-4o-mini"] } }, + models: ["openai/gpt-4.1"], + }, + { + name: "managed-speculative", + strategy: "priority", + config: { zeroLatencyOptimizationsEnabled: true, hedging: true }, + models: ["openai/gpt-4.1", "openai/gpt-4o-mini"], + }, + { + name: "managed-fixed-multi-account", + strategy: "priority", + models: [ + { model: "openai/gpt-4.1", connectionId: firstConnection.id }, + { model: "openai/gpt-4o-mini", connectionId: secondConnection.id }, + ], + }, + ]; + for (const combo of cases) await combosDb.createCombo(combo); + await combosDb.createCombo({ + name: "managed-nested-fusion", + strategy: "priority", + config: { nestedComboMode: "execute" }, + models: [{ kind: "combo-ref", comboName: "managed-fusion" }], + }); + + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + throw new Error("unexpected provider dispatch"); + }; + for (const model of [...cases.map((combo) => combo.name), "managed-nested-fusion"]) { + const response = await handleChat( + managedRequest(key.key, acquired.lease.generation, {}, { model }) + ); + assert.equal(response.status, 409, model); + assert.equal((await response.json()).error.code, "LEASE_UNSUPPORTED_ROUTE", model); + } + assert.equal(dispatches, 0); +}); + +test("one-step managed pipeline uses the ordinary fenced lease path", async () => { + const connection = await seedConnection("openai"); + const key = await seedManagedKey([connection.id]); + const acquired = leaseDb.acquireExclusiveConnectionLease({ + leaseOwnerId: OWNER, + apiKeyId: key.id, + provider: "openai", + connectionId: connection.id, + }); + assert.equal(acquired.kind, "ACQUIRED"); + if (acquired.kind !== "ACQUIRED") return; + await combosDb.createCombo({ + name: "managed-pipeline-one", + strategy: "pipeline", + config: { maxRetries: 0 }, + models: ["openai/gpt-4.1"], + }); + let dispatches = 0; + globalThis.fetch = async () => { + dispatches += 1; + return buildOpenAIResponse("one-step success"); + }; + + const response = await handleChat( + managedRequest(key.key, acquired.lease.generation, {}, { model: "managed-pipeline-one" }) + ); + assert.equal(response.status, 200); + assert.equal(dispatches, 1); +}); diff --git a/tests/unit/chatcore-executor-client-headers.test.ts b/tests/unit/chatcore-executor-client-headers.test.ts index 92ff2a8c5b..b6f553aafc 100644 --- a/tests/unit/chatcore-executor-client-headers.test.ts +++ b/tests/unit/chatcore-executor-client-headers.test.ts @@ -40,3 +40,13 @@ test("does not overwrite an existing user-agent header", () => { test("a trimmed-empty user agent does not create headers on its own", () => { assert.equal(buildExecutorClientHeaders({}, " "), null); }); + +test("internal hard-lease control headers never reach an executor", () => { + const out = buildExecutorClientHeaders({ + "X-OmniRoute-Lease-Owner": `vlo_${"A".repeat(43)}`, + "x-omniroute-lease-generation": "7", + "x-session-id": "routing-session-remains-independent", + }); + + assert.deepEqual(out, { "x-session-id": "routing-session-remains-independent" }); +}); diff --git a/tests/unit/chatcore-log-truncation.test.ts b/tests/unit/chatcore-log-truncation.test.ts index dc257cc562..db83a81b15 100644 --- a/tests/unit/chatcore-log-truncation.test.ts +++ b/tests/unit/chatcore-log-truncation.test.ts @@ -214,14 +214,8 @@ test("truncateForLog keeps a bounded `tools` field alive when the request is sum assert.ok(summary.tools, "expected the summary to retain a `tools` field"); const clonedTools = summary.tools as Array>; assert.equal(clonedTools.length, tools.length); - assert.equal( - (clonedTools[0].function as Record).name, - "get_weather" - ); - assert.equal( - (clonedTools[1].function as Record).name, - "search_web" - ); + assert.equal((clonedTools[0].function as Record).name, "get_weather"); + assert.equal((clonedTools[1].function as Record).name, "search_web"); }); test("truncateForLog bounds an oversized `tools` array to the configured tail-item cap", () => { diff --git a/tests/unit/chatcore-noauth-echo-model-10571.test.ts b/tests/unit/chatcore-noauth-echo-model-10571.test.ts new file mode 100644 index 0000000000..c8a03c240e --- /dev/null +++ b/tests/unit/chatcore-noauth-echo-model-10571.test.ts @@ -0,0 +1,45 @@ +/** + * Regression test for PR #10571 — chatCore auto-echoes the listing-valid + * `/` form in the response `model` field for bare (unprefixed) + * requests routed to a no-auth catalog provider (e.g. `opencode`), so clients + * that validate `response.model` against the provider's entry in + * `/v1/models` (which lists models under the provider's alias prefix) don't + * warn/reject. + * + * `resolveNoAuthEchoModel()` (`open-sse/handlers/chatCore/noAuthEchoModel.ts`) + * is a pure extraction of the inline logic chatCore.ts wires into its + * `echoModel` computation. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { resolveNoAuthEchoModel } from "../../open-sse/handlers/chatCore/noAuthEchoModel.ts"; +import { REGISTRY } from "../../open-sse/config/providerRegistry.ts"; + +test("aliases a bare model routed to a no-auth provider to /", () => { + const alias = REGISTRY["opencode"]?.alias; + assert.ok(alias, "opencode must declare an alias in the registry for this test to be meaningful"); + assert.equal(resolveNoAuthEchoModel("big-pickle", "opencode"), `${alias}/big-pickle`); +}); + +test("is a no-op (returns null) for an unregistered provider id", () => { + assert.equal(resolveNoAuthEchoModel("some-model", "provider-with-no-registry-entry"), null); +}); + +test("is a no-op (returns null) for a non-noAuth provider", () => { + assert.equal(resolveNoAuthEchoModel("gpt-5.5", "openai"), null); +}); + +test("is a no-op (returns null) when the requested model already has a provider prefix", () => { + assert.equal(resolveNoAuthEchoModel("opencode/big-pickle", "opencode"), null); +}); + +test("is a no-op (returns null) for empty/non-string requested model", () => { + assert.equal(resolveNoAuthEchoModel("", "opencode"), null); + assert.equal(resolveNoAuthEchoModel(null, "opencode"), null); + assert.equal(resolveNoAuthEchoModel(undefined, "opencode"), null); +}); + +test("is a no-op (returns null) for a null/undefined provider", () => { + assert.equal(resolveNoAuthEchoModel("big-pickle", null), null); + assert.equal(resolveNoAuthEchoModel("big-pickle", undefined), null); +}); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index c82d6987b2..f28caccf09 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -309,6 +309,8 @@ async function invokeChatCore({ onCredentialsRefreshed = null, onRequestSuccess = null, sessionAffinityKey = null, + managedLease = null, + cachedSettings = null, }: any = {}) { const calls: any[] = []; @@ -355,6 +357,8 @@ async function invokeChatCore({ sessionAffinityKey, isCombo, comboStrategy, + managedLease, + cachedSettings, onCredentialsRefreshed, onRequestSuccess, } as any); diff --git a/tests/unit/chatcore-upstream-body.test.ts b/tests/unit/chatcore-upstream-body.test.ts index 685a545fb2..2f1fe135a9 100644 --- a/tests/unit/chatcore-upstream-body.test.ts +++ b/tests/unit/chatcore-upstream-body.test.ts @@ -50,6 +50,83 @@ test("leaves the model untouched when it already matches", async () => { assert.equal(out.model, "model-a"); }); +test("defaults OpenAI image inputs to high detail for OpenCode clients without overriding explicit detail", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { + model: "model-a", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Read this screenshot" }, + { type: "image_url", image_url: { url: "data:image/png;base64,test" } }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,test", detail: "low" }, + }, + ], + }, + ], + }, + modelToCall: "model-a", + provider: "opencode-zen", + targetFormat: FORMATS.OPENAI, + credentials: null, + isOpencodeClient: true, + }); + + const content = ( + out.messages as Array<{ content: Array<{ image_url?: { detail?: string } }> }> + )[0].content; + assert.equal(content[1].image_url?.detail, "high"); + assert.equal(content[2].image_url?.detail, "low"); +}); + +test("defaults Responses input images to high detail for OpenCode clients", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { + model: "model-a", + input: [ + { + role: "user", + content: [{ type: "input_image", image_url: "data:image/png;base64,test" }], + }, + ], + }, + modelToCall: "model-a", + provider: "opencode-zen", + targetFormat: FORMATS.OPENAI_RESPONSES, + credentials: null, + isOpencodeClient: true, + }); + + const content = (out.input as Array<{ content: Array<{ detail?: string }> }>)[0].content; + assert.equal(content[0].detail, "high"); +}); + +test("leaves image detail untouched for non-OpenCode clients on the same provider", async () => { + const out = await prepareUpstreamBody({ + translatedBody: { + model: "model-a", + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,test" } }], + }, + ], + }, + modelToCall: "model-a", + provider: "opencode-zen", + targetFormat: FORMATS.OPENAI, + credentials: null, + }); + + const content = ( + out.messages as Array<{ content: Array<{ image_url?: { detail?: string } }> }> + )[0].content; + assert.equal(content[0].image_url?.detail, undefined); +}); + test("strips Codex GPT-5 verbosity after routing resolves to opencode-go/GLM", async () => { const translatedBody = { model: "glm-5.2", diff --git a/tests/unit/check-migration-numbering.test.ts b/tests/unit/check-migration-numbering.test.ts index 17d1416e86..0d5c152896 100644 --- a/tests/unit/check-migration-numbering.test.ts +++ b/tests/unit/check-migration-numbering.test.ts @@ -108,7 +108,8 @@ test("frozen allowlists match the documented legacy and stacked-series gaps", () assert.equal((KNOWN_GAPS as Set).has("145"), false); // 147 left the gap list when 147_api_keys_model_access_mode.sql landed (same pattern as 143). assert.equal((KNOWN_GAPS as Set).has("147"), false); - assert.ok((KNOWN_GAPS as Set).has("148")); + // 148 left the gap list when 148_provider_quota_state.sql landed on this branch (same pattern as 143/147). + assert.equal((KNOWN_GAPS as Set).has("148"), false); // 149 left the gap list when 149_api_key_combo_access.sql landed (#10066). assert.equal((KNOWN_GAPS as Set).has("149"), false); // "041" was removed from KNOWN_DUPLICATE_VERSIONS in 6A.3 (stale: no physical diff --git a/tests/unit/claude-context-1m-supported-models.test.ts b/tests/unit/claude-context-1m-supported-models.test.ts index 202f90ba63..176eecdcfe 100644 --- a/tests/unit/claude-context-1m-supported-models.test.ts +++ b/tests/unit/claude-context-1m-supported-models.test.ts @@ -11,7 +11,9 @@ function parseModelList(constantName: string): string[] { const sourceFile = constantName === "CONTEXT_1M_NATIVE_MODELS" ? "open-sse/config/claudeCodeCompatibleIdentity.ts" - : "open-sse/services/claudeCodeCompatible.ts"; + : constantName === "CONTEXT_1M_SUPPORTED_MODELS" + ? "open-sse/config/context1m.ts" + : "open-sse/services/claudeCodeCompatible.ts"; const src = fs.readFileSync(path.join(REPO_ROOT, sourceFile), "utf8"); // Strip type annotations before matching to handle `const X: string[] = [...]` const match = src diff --git a/tests/unit/claude-tool-name-casing-fix.test.ts b/tests/unit/claude-tool-name-casing-fix.test.ts new file mode 100644 index 0000000000..c02fd09286 --- /dev/null +++ b/tests/unit/claude-tool-name-casing-fix.test.ts @@ -0,0 +1,229 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { geminiToClaudeResponse } from "../../open-sse/translator/response/gemini-to-claude.ts"; +import { openaiToClaudeResponse } from "../../open-sse/translator/response/openai-to-claude.ts"; +import { restoreClaudeToolName } from "../../open-sse/services/claudeCodeToolRemapper.ts"; +import { restoreClaudePassthroughToolUseName } from "../../open-sse/utils/stream.ts"; +import { + buildGeminiThoughtSignatureKey, + getGeminiThoughtSignature, +} from "../../open-sse/services/geminiThoughtSignatureStore.ts"; + +interface ClaudeEvent { + type: string; + index?: number; + content_block?: { type: string; id?: string; name?: string; input?: unknown }; +} + +type TranslatorState = Record; + +function firstToolUse(events: ClaudeEvent[] | null): ClaudeEvent["content_block"] { + return events?.find( + (e) => e.type === "content_block_start" && e.content_block?.type === "tool_use" + )?.content_block; +} + +describe("Claude Code Tool Name Casing Fixes", () => { + it("restoreClaudeToolName maps lowercase tool names to PascalCase", () => { + assert.equal(restoreClaudeToolName("bash"), "Bash"); + assert.equal(restoreClaudeToolName("read"), "Read"); + assert.equal(restoreClaudeToolName("write"), "Write"); + assert.equal(restoreClaudeToolName("websearch"), "WebSearch"); + assert.equal(restoreClaudeToolName("webfetch"), "WebFetch"); + assert.equal(restoreClaudeToolName("agent"), "Agent"); + assert.equal(restoreClaudeToolName("unknown_third_party", null), "unknown_third_party"); + }); + + it("restoreClaudeToolName covers apply_patch and applypatch variants", () => { + assert.equal(restoreClaudeToolName("apply_patch"), "ApplyPatch"); + assert.equal(restoreClaudeToolName("applypatch"), "ApplyPatch"); + }); + + it("restoreClaudeToolName covers the tools the 7-entry map missed", () => { + assert.equal(restoreClaudeToolName("todowrite"), "TodoWrite"); + assert.equal(restoreClaudeToolName("glob"), "Glob"); + assert.equal(restoreClaudeToolName("grep"), "Grep"); + assert.equal(restoreClaudeToolName("task"), "Task"); + assert.equal(restoreClaudeToolName("skill"), "Skill"); + assert.equal(restoreClaudeToolName("multiedit"), "MultiEdit"); + assert.equal(restoreClaudeToolName("askuserquestion"), "AskUserQuestion"); + assert.equal(restoreClaudeToolName("exitplanmode"), "ExitPlanMode"); + }); + + it("restoreClaudeToolName keeps the #7926 TitleCase→lowercase fallback with no map", () => { + // Clients with no request-side map (XML / OpenCode-style) expect lowercase. + assert.equal(restoreClaudeToolName("TodoWrite"), "todowrite"); + assert.equal(restoreClaudeToolName("Read"), "read"); + }); + + it("restoreClaudeToolName prefers toolNameMap over the static map", () => { + const toolNameMap = new Map([ + ["custom_read", "CustomRead"], + ["read", "mcp__fs__read"], + ]); + assert.equal(restoreClaudeToolName("custom_read", toolNameMap), "CustomRead"); + // A request-side alias wins over the built-in casing table. + assert.equal(restoreClaudeToolName("read", toolNameMap), "mcp__fs__read"); + // Names absent from the map still fall back to the static table. + assert.equal(restoreClaudeToolName("bash", toolNameMap), "Bash"); + }); + + it("geminiToClaudeResponse normalizes lowercase tool names to PascalCase", () => { + const chunk = { + candidates: [ + { + content: { + parts: [{ functionCall: { name: "todowrite", args: { todos: [] } } }], + }, + }, + ], + }; + const state: TranslatorState = {}; + const block = firstToolUse(geminiToClaudeResponse(chunk, state) as ClaudeEvent[]); + assert.equal(block?.name, "TodoWrite"); + }); + + it("geminiToClaudeResponse honors state.toolNameMap ahead of the casing table", () => { + const chunk = { + candidates: [ + { + content: { parts: [{ functionCall: { name: "read", args: {} } }] }, + }, + ], + }; + const state: TranslatorState = { toolNameMap: new Map([["read", "mcp__fs__read"]]) }; + const block = firstToolUse(geminiToClaudeResponse(chunk, state) as ClaudeEvent[]); + assert.equal(block?.name, "mcp__fs__read"); + }); + + it("geminiToClaudeResponse still persists thoughtSignature for follow-up turns (#8979)", () => { + const chunk = { + candidates: [ + { + content: { + parts: [ + { thoughtSignature: "sig-abc123" }, + { functionCall: { id: "call_sig_1", name: "read", args: {} } }, + ], + }, + }, + ], + }; + const state: TranslatorState = { signatureNamespace: "ns-test" }; + const block = firstToolUse(geminiToClaudeResponse(chunk, state) as ClaudeEvent[]); + assert.equal(block?.name, "Read"); + assert.equal( + getGeminiThoughtSignature(buildGeminiThoughtSignatureKey("ns-test", "call_sig_1")), + "sig-abc123" + ); + assert.equal(state.pendingThoughtSignature, null); + }); + + it("geminiToClaudeResponse persists thoughtSignature for text-extracted tool calls", () => { + const chunk = { + candidates: [ + { + content: { + parts: [ + { + thoughtSignature: "sig-text-1", + text: '{"name":"bash","arguments":{"command":"ls"}}', + }, + ], + }, + }, + ], + }; + const state: TranslatorState = { signatureNamespace: "ns-text" }; + const events = geminiToClaudeResponse(chunk, state) as ClaudeEvent[]; + const block = firstToolUse(events); + assert.equal(block?.name, "Bash"); + assert.ok(block?.id); + assert.equal( + getGeminiThoughtSignature(buildGeminiThoughtSignatureKey("ns-text", block!.id!)), + "sig-text-1" + ); + }); + + it("openaiToClaudeResponse normalizes lowercase tool names to PascalCase", () => { + const chunk = { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: "call_123", function: { name: "bash", arguments: "" } }, + ], + }, + }, + ], + }; + const state: TranslatorState = { toolCalls: new Map(), nextBlockIndex: 0 }; + const block = firstToolUse(openaiToClaudeResponse(chunk, state) as ClaudeEvent[]); + assert.equal(block?.name, "Bash"); + }); + + it("openaiToClaudeResponse maps lowercase todowrite to TodoWrite", () => { + const chunk = { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: "call_todo", function: { name: "todowrite", arguments: "" } }, + ], + }, + }, + ], + }; + const state: TranslatorState = { toolCalls: new Map(), nextBlockIndex: 0 }; + const block = firstToolUse(openaiToClaudeResponse(chunk, state) as ClaudeEvent[]); + assert.equal(block?.name, "TodoWrite"); + }); + + it("openaiToClaudeResponse restores request-side aliases via state.toolNameMap", () => { + const chunk = { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, id: "call_alias", function: { name: "SubDispatch", arguments: "" } }, + ], + }, + }, + ], + }; + const state: TranslatorState = { + toolCalls: new Map(), + nextBlockIndex: 0, + toolNameMap: new Map([["SubDispatch", "subagents"]]), + }; + const block = firstToolUse(openaiToClaudeResponse(chunk, state) as ClaudeEvent[]); + assert.equal(block?.name, "subagents"); + }); + + it("restoreClaudePassthroughToolUseName maps lowercase names with no toolNameMap", () => { + const parsed = { content_block: { type: "tool_use", id: "tool_123", name: "read" } }; + assert.equal(restoreClaudePassthroughToolUseName(parsed, null), true); + assert.equal(parsed.content_block.name, "Read"); + }); + + it("restoreClaudePassthroughToolUseName maps lowercase todowrite to TodoWrite", () => { + const parsed = { content_block: { type: "tool_use", id: "tool_todo", name: "todowrite" } }; + assert.equal(restoreClaudePassthroughToolUseName(parsed, null), true); + assert.equal(parsed.content_block.name, "TodoWrite"); + }); + + it("restoreClaudePassthroughToolUseName respects toolNameMap when provided", () => { + const parsed = { content_block: { type: "tool_use", id: "tool_123", name: "custom_tool" } }; + const toolNameMap = new Map([["custom_tool", "CustomTool"]]); + assert.equal(restoreClaudePassthroughToolUseName(parsed, toolNameMap), true); + assert.equal(parsed.content_block.name, "CustomTool"); + }); + + it("restoreClaudePassthroughToolUseName preserves request-declared casing via toolNameMap", () => { + // With the request map present, PascalCase survives (no #7926 lowercasing). + const parsed = { content_block: { type: "tool_use", id: "t", name: "TodoWrite" } }; + const toolNameMap = new Map([["TodoWrite", "TodoWrite"]]); + assert.equal(restoreClaudePassthroughToolUseName(parsed, toolNameMap), false); + assert.equal(parsed.content_block.name, "TodoWrite"); + }); +}); diff --git a/tests/unit/cli-completion-dynamic.test.ts b/tests/unit/cli-completion-dynamic.test.ts index 88c89bc8b6..48529809f4 100644 --- a/tests/unit/cli-completion-dynamic.test.ts +++ b/tests/unit/cli-completion-dynamic.test.ts @@ -93,3 +93,26 @@ test("completion scripts incluem combos/providers/models no cache dinamicamente" "should reference cache" ); }); + +test("completion scripts expõem os alvos de execução e configuração", async () => { + const { runCompletionCommand } = await import("../../bin/cli/commands/completion.mjs"); + const expected = ["connect", "contexts", "configure", "launch", "launch-codex", "run", "repair"]; + + for (const shell of ["bash", "zsh", "fish"] as const) { + const chunks: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: unknown) => { + if (typeof chunk === "string") chunks.push(chunk); + return true; + }) as typeof process.stdout.write; + try { + assert.equal(await runCompletionCommand(shell), 0); + } finally { + process.stdout.write = originalWrite; + } + const output = chunks.join(""); + for (const command of expected) { + assert.ok(output.includes(command), `${shell} completion should include ${command}`); + } + } +}); diff --git a/tests/unit/cli-contexts.test.ts b/tests/unit/cli-contexts.test.ts index 21644a15aa..2ed45bfeba 100644 --- a/tests/unit/cli-contexts.test.ts +++ b/tests/unit/cli-contexts.test.ts @@ -1,6 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -70,11 +70,59 @@ test("resolveActiveContext aceita override pontual", async () => { assert.equal(ctx.baseUrl, "http://staging:20128"); }); +test("saveContextsSecure guarda tokens no keychain e resolve pela referência", async () => { + const { + loadContexts, + saveContextsSecure, + resolveActiveContext, + setContextKeychainBackendForTests, + } = await import("../../bin/cli/contexts.mjs"); + const entries = new Map(); + const fakeKeychain = { + async getPassword(_service: string, account: string) { + return entries.get(account) || null; + }, + async setPassword(_service: string, account: string, value: string) { + entries.set(account, value); + }, + async deletePassword(_service: string, account: string) { + entries.delete(account); + return true; + }, + }; + await setContextKeychainBackendForTests(fakeKeychain); + const cfg = loadContexts(); + cfg.contexts.secure = { + baseUrl: "https://secure.example.com", + accessToken: "oma_test_secret", + scope: "write", + }; + await saveContextsSecure(cfg); + + const persisted = JSON.parse(readFileSync(join(tmpDir, "config.json"), "utf8")); + assert.equal(persisted.contexts.secure.accessToken, undefined); + assert.match(persisted.contexts.secure.credentialRef, /^omniroute-cli:context:/); + assert.equal(resolveActiveContext("secure").accessToken, "oma_test_secret"); + assert.ok(entries.size >= 1); + + await setContextKeychainBackendForTests(null); +}); + test("contexts.mjs (commands) pode ser importado sem erro", async () => { const mod = await import("../../bin/cli/commands/contexts.mjs"); assert.equal(typeof mod.registerContexts, "function"); }); +test("context export redaction covers canonical and legacy profile schemas", async () => { + const { redactContextSecrets } = await import("../../bin/cli/commands/contexts.mjs"); + const redacted = redactContextSecrets({ + contexts: { remote: { accessToken: "oma-secret", apiKey: "sk-secret" } }, + profiles: { legacy: { accessToken: "legacy-secret", apiKey: "legacy-key" } }, + }); + assert.deepEqual(redacted.contexts.remote, { apiKey: null }); + assert.deepEqual(redacted.profiles.legacy, { apiKey: null }); +}); + test("confirm() declines cleanly on non-interactive stdin (no hung await)", async () => { // Regression: `contexts remove` without --yes used to prompt even when stdin // could not answer (pipe/CI/EOF), leaving the readline question pending and diff --git a/tests/unit/cli-expanded-commands.test.ts b/tests/unit/cli-expanded-commands.test.ts index 74bfa2711e..1327351555 100644 --- a/tests/unit/cli-expanded-commands.test.ts +++ b/tests/unit/cli-expanded-commands.test.ts @@ -305,3 +305,54 @@ test("test-provider — compare requer pelo menos dois modelos sem server retorn } assert.ok(code === 0 || code === 1); }); + +test("test-provider --all-providers consumes the connections envelope", async () => { + const origFetch = globalThis.fetch; + const connections = [ + { id: "conn1", provider: "anthropic", defaultModel: "claude", authType: "apikey" }, + { id: "conn2", provider: "gemini", defaultModel: "gemini", authType: "oauth" }, + ]; + const requests: string[] = []; + globalThis.fetch = ((url: string) => { + requests.push(url); + if (url.includes("/api/health")) return Promise.resolve(new Response("{}", { status: 200 })); + if (url.includes("/api/providers?limit=200")) { + return Promise.resolve(new Response(JSON.stringify({ connections }), { status: 200 })); + } + if (url.includes("/api/v1/providers/test")) { + return Promise.resolve(new Response(JSON.stringify({ success: true }), { status: 201 })); + } + throw new Error(`unexpected URL: ${url}`); + }) as typeof fetch; + + try { + const { runTestProviderCommand } = await import("../../bin/cli/commands/test-provider.mjs"); + const output: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + if (typeof chunk === "string") output.push(chunk); + return true; + }) as typeof process.stdout.write; + try { + const code = await runTestProviderCommand(undefined, undefined, { + allProviders: true, + json: true, + }); + assert.equal(code, 0); + } finally { + process.stdout.write = origWrite; + } + assert.ok(requests.some((url) => url.includes("/api/providers?limit=200"))); + const parsed = JSON.parse(output.join("")); + assert.deepEqual( + parsed.map(({ provider, model }: { provider: string; model: string }) => ({ provider, model })), + [ + { provider: "anthropic", model: "claude" }, + { provider: "gemini", model: "gemini" }, + ], + ); + assert.ok(parsed.every(({ success }: { success: boolean }) => success)); + } finally { + globalThis.fetch = origFetch; + } +}); diff --git a/tests/unit/cli-helper/config-generator-codex.test.ts b/tests/unit/cli-helper/config-generator-codex.test.ts new file mode 100644 index 0000000000..7d3843db57 --- /dev/null +++ b/tests/unit/cli-helper/config-generator-codex.test.ts @@ -0,0 +1,143 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { parse } from "smol-toml"; + +import { + generateCodexConfig, + findLegacyCodexYaml, +} from "../../../src/lib/cli-helper/config-generator/codex.ts"; +import { generateConfig } from "../../../src/lib/cli-helper/config-generator/index.ts"; + +interface ParsedCodexToml { + model?: string; + model_provider?: string; + tool_output_token_limit?: number; + model_providers: Record< + string, + { name?: string; base_url?: string; env_key?: string; requires_openai_auth?: boolean } + >; +} + +const tmpDirs: string[] = []; +function tempCodexHome(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-gen-")); + tmpDirs.push(dir); + return dir; +} + +after(() => { + for (const dir of tmpDirs) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + } +}); + +describe("config-generator codex (TOML)", () => { + it("generates modern TOML with env_key auth and never embeds the API key", async () => { + const home = tempCodexHome(); + const content = await generateCodexConfig({ + baseUrl: "http://localhost:20128/", + apiKey: "sk_live_secret_value", + model: "glm/glm-5.2", + configPath: path.join(home, "config.toml"), + }); + + assert.ok(!content.includes("sk_live_secret_value"), "API key must not be written"); + const parsed = parse(content) as unknown as ParsedCodexToml; + assert.strictEqual(parsed.model, "glm/glm-5.2"); + assert.strictEqual(parsed.model_provider, "omniroute"); + assert.strictEqual(parsed.model_providers.omniroute.base_url, "http://localhost:20128/v1"); + assert.strictEqual(parsed.model_providers.omniroute.env_key, "OMNIROUTE_API_KEY"); + assert.strictEqual(parsed.model_providers.omniroute.requires_openai_auth, false); + }); + + it("normalizes a baseUrl that already ends in /v1", async () => { + const home = tempCodexHome(); + const content = await generateCodexConfig({ + baseUrl: "https://relay.example.test/v1", + apiKey: "sk-test", + configPath: path.join(home, "config.toml"), + }); + const parsed = parse(content) as unknown as ParsedCodexToml; + assert.strictEqual(parsed.model_providers.omniroute.base_url, "https://relay.example.test/v1"); + assert.ok(!("model" in parsed), "model key is omitted when no model is chosen"); + }); + + it("merges conservatively with an existing config.toml, preserving operator keys", async () => { + const home = tempCodexHome(); + const configPath = path.join(home, "config.toml"); + fs.writeFileSync( + configPath, + [ + 'model = "old/model"', + "tool_output_token_limit = 32768", + "", + "[model_providers.other]", + 'name = "Other"', + 'base_url = "https://other.example/v1"', + ].join("\n"), + "utf-8" + ); + + const content = await generateCodexConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + model: "glm/glm-5.2", + configPath, + }); + const parsed = parse(content) as unknown as ParsedCodexToml; + assert.strictEqual(parsed.tool_output_token_limit, 32768, "unrelated key preserved"); + assert.strictEqual(parsed.model_providers.other.name, "Other", "other provider preserved"); + assert.strictEqual(parsed.model, "glm/glm-5.2", "model updated"); + assert.strictEqual(parsed.model_provider, "omniroute"); + assert.ok(parsed.model_providers.omniroute, "omniroute provider added"); + }); + + it("refuses to overwrite an existing config.toml that is not valid TOML", async () => { + const home = tempCodexHome(); + const configPath = path.join(home, "config.toml"); + fs.writeFileSync(configPath, "this is { not [ valid toml =", "utf-8"); + + await assert.rejects( + () => + generateCodexConfig({ + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + configPath, + }), + /not valid TOML/ + ); + assert.strictEqual( + fs.readFileSync(configPath, "utf-8"), + "this is { not [ valid toml =", + "invalid file left untouched" + ); + }); + + it("detects a leftover legacy config.yaml for migration messaging", () => { + const home = tempCodexHome(); + assert.strictEqual(findLegacyCodexYaml(home), null, "absent yaml → no migration"); + fs.writeFileSync(path.join(home, "config.yaml"), "openai:\n base_url: x\n", "utf-8"); + assert.strictEqual(findLegacyCodexYaml(home), path.join(home, "config.yaml")); + }); + + it("generateConfig(codex) targets ~/.codex/config.toml (not the legacy yaml)", async () => { + const result = await generateConfig("codex", { + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + // Success depends on the operator's real ~/.codex/config.toml being valid; + // the path contract is what must hold either way. + assert.ok(result.configPath.endsWith(path.join(".codex", "config.toml"))); + if (result.success) { + assert.ok(String(result.content).includes("[model_providers.omniroute]")); + assert.ok(!String(result.content).includes("sk-test")); + } + }); +}); diff --git a/tests/unit/cli-helper/config-generator.test.ts b/tests/unit/cli-helper/config-generator.test.ts index c20a91c51b..fde409b940 100644 --- a/tests/unit/cli-helper/config-generator.test.ts +++ b/tests/unit/cli-helper/config-generator.test.ts @@ -110,6 +110,16 @@ describe("config-generator", () => { assert.ok("configPath" in result); }); + it("accepts the legacy kilocode id while generating the canonical kilo config", async () => { + const result = await generator.generateConfig("kilocode", { + baseUrl: "http://localhost:20128", + apiKey: "sk-test", + }); + assert.strictEqual(result.success, true); + assert.ok(result.configPath.includes(".config/kilocode/settings.json")); + assert.ok(String(result.content).includes("http://localhost:20128/v1")); + }); + it("returns success for valid hermes config", async () => { const result = await generator.generateConfig("hermes", { baseUrl: "http://localhost:20128", diff --git a/tests/unit/cli-helper/tool-detector.test.ts b/tests/unit/cli-helper/tool-detector.test.ts index c32f0fa93a..fa466ed7b2 100644 --- a/tests/unit/cli-helper/tool-detector.test.ts +++ b/tests/unit/cli-helper/tool-detector.test.ts @@ -61,10 +61,18 @@ describe("tool-detector", () => { assert.strictEqual(result!.version, "0.3.1"); assert.ok( result!.configPath.includes(".openclaw/openclaw.json"), - `expected configPath to include '.openclaw/openclaw.json', got: ${result!.configPath}`, + `expected configPath to include '.openclaw/openclaw.json', got: ${result!.configPath}` ); assert.strictEqual(typeof result!.configured, "boolean"); }); + + it("normalizes the legacy kilocode id to the canonical kilo target", async () => { + const result = await toolDetector.detectTool("kilocode"); + assert.ok(result !== null); + assert.strictEqual(result!.id, "kilo"); + assert.strictEqual(result!.name, "Kilo Code"); + assert.ok(result!.configPath.includes(".local/share/kilo/auth.json")); + }); }); describe("detectAllTools", () => { @@ -85,11 +93,14 @@ describe("tool-detector", () => { it("includes openclaw in the detected tools list", async () => { const tools = await toolDetector.detectAllTools(); const openclaw = tools.find((t) => t.id === "openclaw"); - assert.ok(openclaw !== undefined, "detectAllTools() must include an entry with id='openclaw'"); + assert.ok( + openclaw !== undefined, + "detectAllTools() must include an entry with id='openclaw'" + ); assert.strictEqual(openclaw!.name, "OpenClaw"); assert.ok( openclaw!.configPath.includes(".openclaw/openclaw.json"), - `expected configPath to include '.openclaw/openclaw.json', got: ${openclaw!.configPath}`, + `expected configPath to include '.openclaw/openclaw.json', got: ${openclaw!.configPath}` ); }); }); diff --git a/tests/unit/cli-machine-token.test.ts b/tests/unit/cli-machine-token.test.ts index 8b8d6f762a..833bf67a0c 100644 --- a/tests/unit/cli-machine-token.test.ts +++ b/tests/unit/cli-machine-token.test.ts @@ -1,6 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import crypto from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; test("cliToken.mjs pode ser importado sem erro", async () => { const mod = await import("../../bin/cli/utils/cliToken.mjs"); @@ -9,12 +12,38 @@ test("cliToken.mjs pode ser importado sem erro", async () => { assert.equal(mod.CLI_TOKEN_HEADER, "x-omniroute-cli-token"); }); -test("getCliToken retorna string de 32 chars ou string vazia", async () => { +test("getCliToken retorna string de 64 chars ou string vazia", async () => { const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); const token = await getCliToken(); assert.ok(typeof token === "string"); - // Pode ser "" se node-machine-id falhar, ou 32 chars se funcionar. - assert.ok(token === "" || token.length === 32, `expected 0 or 32 chars, got ${token.length}`); + // Pode ser "" se node-machine-id falhar, ou 64 chars se funcionar + // (HMAC-SHA256 digest hex — see #10148 cliToken hardening). + assert.ok(token === "" || token.length === 64, `expected 0 or 64 chars, got ${token.length}`); +}); + +test("getCliToken deriva token de 64 chars sob o node puro que a CLI usa", async () => { + const mod = await import("node-machine-id"); + const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync; + // Sem machine-id nesta plataforma não há token a derivar — nada a afirmar. + if (typeof machineIdSync !== "function") return; + + // Precisa rodar em `node` puro, sem o loader tsx/esm: o tsx resolve os named + // exports de um CJS e mascara o bug de interop. `omniroute` roda sob node puro, + // onde `const { machineIdSync } = await import(...)` dava undefined, o catch + // zerava o token e TODA requisição de management saía sem autenticação. + const repoRoot = fileURLToPath(new URL("../..", import.meta.url)); + const entry = pathToFileURL(join(repoRoot, "bin/cli/utils/cliToken.mjs")).href; + const out = execFileSync( + process.execPath, + [ + "-e", + `import(${JSON.stringify(entry)}).then(m => m.getCliToken()).then(t => console.log(t.length))`, + ], + { cwd: repoRoot, encoding: "utf8" } + ); + + // HMAC-SHA256 digest hex = 64 chars (#10148 cliToken hardening). + assert.equal(out.trim(), "64", `expected a derived 64-char token, got length ${out.trim()}`); }); test("getCliToken retorna mesmo valor em chamadas repetidas (cache)", async () => { @@ -24,11 +53,35 @@ test("getCliToken retorna mesmo valor em chamadas repetidas (cache)", async () = assert.equal(t1, t2); }); +test("getCliToken respeita rotação de OMNIROUTE_CLI_SALT", async () => { + const mod = await import("node-machine-id"); + const machineIdSync = mod.machineIdSync ?? mod.default?.machineIdSync; + if (typeof machineIdSync !== "function") return; + + const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); + const original = process.env.OMNIROUTE_CLI_SALT; + try { + delete process.env.OMNIROUTE_CLI_SALT; + const withDefaultSalt = await getCliToken(); + process.env.OMNIROUTE_CLI_SALT = "rotated-salt-for-test"; + const withRotatedSalt = await getCliToken(); + // docs/security/CLI_TOKEN.md promete que a rotação alcança os processos CLI; + // o SALT hardcoded ignorava a env var e devolvia sempre o mesmo token. + assert.notEqual(withRotatedSalt, withDefaultSalt); + // HMAC-SHA256 digest hex = 64 chars (#10148 cliToken hardening). + assert.equal(withRotatedSalt.length, 64); + } finally { + if (original === undefined) delete process.env.OMNIROUTE_CLI_SALT; + else process.env.OMNIROUTE_CLI_SALT = original; + } +}); + test("getCliToken produz apenas hex lowercase se não-vazio", async () => { const { getCliToken } = await import("../../bin/cli/utils/cliToken.mjs"); const token = await getCliToken(); if (token.length > 0) { - assert.match(token, /^[0-9a-f]{32}$/); + // HMAC-SHA256 digest hex = 64 chars (#10148 cliToken hardening). + assert.match(token, /^[0-9a-f]{64}$/); } }); @@ -70,17 +123,17 @@ test("isLoopback rejeita IP público", async () => { test("token derivado de machine-id diferente produz hash diferente", () => { const SALT = "omniroute-cli-auth-v1"; + // Mirror the production derivation (#10148): HMAC-SHA256(machineId, SALT) hex. const hash = (mid: string) => crypto - .createHash("sha256") - .update(mid + SALT) - .digest("hex") - .substring(0, 32); + .createHmac("sha256", mid) + .update(SALT) + .digest("hex"); const t1 = hash("machine-id-host-A"); const t2 = hash("machine-id-host-B"); assert.notEqual(t1, t2); - assert.match(t1, /^[0-9a-f]{32}$/); - assert.match(t2, /^[0-9a-f]{32}$/); + assert.match(t1, /^[0-9a-f]{64}$/); + assert.match(t2, /^[0-9a-f]{64}$/); }); test("OMNIROUTE_DISABLE_CLI_TOKEN desabilita auth (estrutura verificada)", async () => { diff --git a/tests/unit/cli-oauth-commands.test.ts b/tests/unit/cli-oauth-commands.test.ts index ffc17b68f3..63301f818f 100644 --- a/tests/unit/cli-oauth-commands.test.ts +++ b/tests/unit/cli-oauth-commands.test.ts @@ -95,6 +95,26 @@ test("runOAuthStatus filtra por provider", async () => { assert.ok(capturedUrl.includes("provider=gemini")); }); +test("runOAuthStatus consumes the connections envelope", async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = ((url: string) => { + assert.ok(url.includes("/api/providers")); + return Promise.resolve(makeResp({ connections: CONNECTIONS })); + }) as any; + + try { + const { runOAuthStatus } = await import("../../bin/cli/commands/oauth.mjs"); + const out = await captureStdout(() => runOAuthStatus({}, makeCmd() as any)); + const parsed = JSON.parse(out); + assert.deepEqual( + parsed.map((connection: { id: string }) => connection.id), + ["conn1", "conn2"], + ); + } finally { + globalThis.fetch = origFetch; + } +}); + test("runOAuthRevoke com --yes chama endpoint de revogação", async () => { let capturedUrl = ""; let capturedMethod = ""; diff --git a/tests/unit/cli-remote-mode.test.ts b/tests/unit/cli-remote-mode.test.ts index 61546d8c94..f8d060ff6d 100644 --- a/tests/unit/cli-remote-mode.test.ts +++ b/tests/unit/cli-remote-mode.test.ts @@ -258,7 +258,7 @@ test("createProgram wires the remote-mode commands into the real CLI program", a } const contexts = program.commands.find((c: any) => c.name() === "contexts"); const subs = contexts.commands.map((c: any) => c.name()); - for (const sub of ["list", "use", "current"]) { + for (const sub of ["list", "use", "current", "migrate"]) { assert.ok(subs.includes(sub), `expected 'contexts ${sub}' subcommand, got: ${subs.join(", ")}`); } }); diff --git a/tests/unit/cli-runtime-detection.test.ts b/tests/unit/cli-runtime-detection.test.ts index 1e1e7fedd0..9e9205a98e 100644 --- a/tests/unit/cli-runtime-detection.test.ts +++ b/tests/unit/cli-runtime-detection.test.ts @@ -9,7 +9,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -const { getCliRuntimeStatus, getKnownToolPaths, CLI_TOOL_IDS } = +const { getCliRuntimeStatus, getKnownToolPaths, normalizeCliToolId, CLI_TOOL_IDS } = await import("../../src/shared/services/cliRuntime.ts"); // ─── Helpers ────────────────────────────────────────────────── @@ -81,6 +81,16 @@ describe("CLI_TOOL_IDS", () => { }); }); +describe("CLI tool id compatibility aliases", () => { + it("normalizes legacy binary names without creating duplicate ids", () => { + assert.equal(normalizeCliToolId("kilocode"), "kilo"); + assert.equal(normalizeCliToolId("kilo-code"), "kilo"); + assert.equal(normalizeCliToolId("openai-codex"), "codex"); + assert.equal(normalizeCliToolId("cc"), "claude"); + assert.equal(normalizeCliToolId("unknown-tool"), "unknown-tool"); + }); +}); + // ─── Size Threshold (30 bytes) ──────────────────────────────── describe("Size threshold — checkKnownPath", () => { diff --git a/tests/unit/cli-tools.test.ts b/tests/unit/cli-tools.test.ts index 5156d3d97b..123c98e600 100644 --- a/tests/unit/cli-tools.test.ts +++ b/tests/unit/cli-tools.test.ts @@ -10,6 +10,7 @@ const { normalizeCliCompatProviderId, } = await import("../../src/shared/constants/cliCompatProviders.ts"); const { CLI_TOOL_IDS } = await import("../../src/shared/services/cliRuntime.ts"); +const { hasRegisteredAgent } = await import("../../src/lib/acp/registry.ts"); const { applyFingerprint, isCliCompatEnabled, setCliCompatProviders } = await import("../../open-sse/config/cliFingerprints.ts"); @@ -31,6 +32,11 @@ test("Hermes quick-config is registered as a guide-based CLI tool", () => { assert.ok(CLI_TOOL_IDS.includes("hermes")); }); +test("ACP registry accepts the Gemini CLI target used by the manager", () => { + assert.equal(hasRegisteredAgent("gemini"), true); + assert.equal(hasRegisteredAgent("definitely-not-an-agent"), false); +}); + test("CLI fingerprint toggles only expose implemented fingerprints and functional legacy aliases", () => { const implemented = new Set(IMPLEMENTED_CLI_FINGERPRINT_PROVIDER_IDS); diff --git a/tests/unit/cli/cli-manifest-drift.test.ts b/tests/unit/cli/cli-manifest-drift.test.ts new file mode 100644 index 0000000000..30e53c96cb --- /dev/null +++ b/tests/unit/cli/cli-manifest-drift.test.ts @@ -0,0 +1,127 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + CLI_TARGET_MANIFEST, + listManifestTargets, + manifestModelArgs, + manifestRequiresModel, + resolveManifestTarget, +} from "../../../bin/cli/cli-manifest.mjs"; +import { listRunTargets, resolveRunTarget } from "../../../bin/cli/commands/run.mjs"; +import { listConfigureTargets, SETUP_MODULES } from "../../../bin/cli/commands/configure.mjs"; +import { runCompletionCommand } from "../../../bin/cli/commands/completion.mjs"; +import { + CLI_TOOL_IDS, + CLI_TOOL_ALIASES, + normalizeCliToolId, + getCliConfigPaths, +} from "../../../src/shared/services/cliRuntime"; +import { getCliTool } from "../../../src/shared/constants/cliTools"; + +/** + * Drift guard for the executable manifest (`bin/cli/cli-manifest.mjs`). + * + * The manifest is the single declaration of which targets `omniroute run` / + * `omniroute configure` / shell completion expose. These assertions fail as + * soon as any consumer surface — or the server-side runtime catalog — starts + * disagreeing with it silently. + */ + +const manifestIds = Object.keys(CLI_TARGET_MANIFEST); + +test("every manifest target is a canonical id in the runtime catalog", () => { + for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) { + assert.equal(normalizeCliToolId(id), id, `${id} must be canonical (not an alias)`); + assert.ok(CLI_TOOL_IDS.includes(id), `${id} must exist in cliRuntime CLI_TOOLS`); + assert.ok(getCliConfigPaths(id), `${id} must resolve config paths in the runtime`); + // Configure targets surface in the dashboard picker flows, so they must be + // cataloged for the UI. Run-only targets (e.g. gemini) may stay CLI-only. + if (entry.configure) { + assert.ok(getCliTool(id), `${id} must exist in the UI catalog (cliTools.ts)`); + } + } +}); + +test("manifest aliases never conflict with runtime aliases", () => { + for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) { + for (const alias of entry.aliases) { + const runtimeTarget = CLI_TOOL_ALIASES[alias]; + if (runtimeTarget !== undefined) { + assert.equal( + runtimeTarget, + id, + `alias '${alias}' maps to '${id}' in the manifest but '${runtimeTarget}' in cliRuntime` + ); + } + } + } +}); + +test("kilocode variants stay a single canonical target in both worlds", () => { + for (const legacy of ["kilocode", "kilo-code", "kilo_cli"]) { + assert.equal(resolveManifestTarget(legacy, "configure"), "kilo"); + assert.equal(normalizeCliToolId(legacy), "kilo"); + } +}); + +test("run command derives targets and aliases from the manifest", () => { + assert.deepEqual(listRunTargets(), listManifestTargets("run")); + for (const [id, entry] of Object.entries(CLI_TARGET_MANIFEST)) { + const expected = entry.run ? id : undefined; + assert.equal(resolveRunTarget(id), expected, `resolveRunTarget(${id})`); + for (const alias of entry.aliases) { + assert.equal(resolveRunTarget(alias), expected, `resolveRunTarget(${alias})`); + } + } + assert.equal(resolveRunTarget("definitely-not-a-cli"), undefined); +}); + +test("configure command derives targets from the manifest and has a recipe per target", () => { + assert.deepEqual(listConfigureTargets(), listManifestTargets("configure")); + for (const id of listManifestTargets("configure")) { + const hasRecipe = id === "codex" || Boolean(SETUP_MODULES[id]); + assert.ok(hasRecipe, `configure target '${id}' has no setup recipe`); + } + for (const id of Object.keys(SETUP_MODULES)) { + assert.ok( + listManifestTargets("configure").includes(id), + `setup recipe '${id}' is not a manifest configure target` + ); + } +}); + +test("completion scripts embed the manifest-derived target lists", async () => { + const runWords = listManifestTargets("run").join(" "); + const configureWords = listManifestTargets("configure").join(" "); + + for (const shell of ["bash", "zsh", "fish"] as const) { + const chunks: string[] = []; + const originalWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: unknown) => { + if (typeof chunk === "string") chunks.push(chunk); + return true; + }) as typeof process.stdout.write; + try { + assert.equal(await runCompletionCommand(shell), 0); + } finally { + process.stdout.write = originalWrite; + } + const output = chunks.join(""); + assert.ok(output.includes(runWords), `${shell} completion must list run targets`); + assert.ok(output.includes(configureWords), `${shell} completion must list configure targets`); + } +}); + +test("model-flag wiring stays declared in the manifest", () => { + assert.deepEqual(manifestModelArgs("aider", "glm/glm-5.2"), ["--model", "openai/glm/glm-5.2"]); + assert.deepEqual(manifestModelArgs("opencode", "glm/glm-5.2"), [ + "--model", + "omniroute/glm/glm-5.2", + ]); + assert.deepEqual(manifestModelArgs("qwen", "glm/glm-5.2"), ["--model", "glm/glm-5.2"]); + assert.deepEqual(manifestModelArgs("claude", "glm/glm-5.2"), []); + assert.deepEqual(manifestModelArgs("codex", "glm/glm-5.2"), []); + assert.equal(manifestRequiresModel("qwen"), true); + assert.equal(manifestRequiresModel("aider"), false); +}); diff --git a/tests/unit/cli/configure-command.test.ts b/tests/unit/cli/configure-command.test.ts new file mode 100644 index 0000000000..fa2d3d2d3e --- /dev/null +++ b/tests/unit/cli/configure-command.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { + listConfigureTargets, + profileNameFromModel, + resolveConfigureTargetOptions, + rankPreferredModels, + getModelPreferenceState, +} = await import("../../../bin/cli/commands/configure.mjs"); + +test("configure picker exposes setup-backed CLI targets (manifest declaration order)", () => { + assert.deepEqual(listConfigureTargets(), [ + "claude", + "codex", + "aider", + "goose", + "opencode", + "qwen", + "cline", + "continue", + "kilo", + ]); +}); + +test("configure picker derives stable profile names from provider/model ids", () => { + assert.equal(profileNameFromModel("glm/glm-5.2"), "glm52"); + assert.equal(profileNameFromModel("claude-sonnet-4.6"), "claudesonnet46"); +}); + +test("configure picker materializes explicit remote/base-url targets", () => { + assert.deepEqual( + resolveConfigureTargetOptions({ + baseUrl: "https://relay.example.test/v1", + apiKey: "sk_test", + port: "2999", + }), + { + baseUrl: "https://relay.example.test/v1", + remote: "https://relay.example.test/v1", + apiKey: "sk_test", + port: "2999", + } + ); +}); + +test("configure picker ranks favorites and recent model ids without leaking context data", () => { + const ranked = rankPreferredModels("codex", ["glm/slow", "glm/fast", "qwen/recent"], { + targets: { codex: { favorites: ["glm/fast"], recent: ["qwen/recent"] } }, + }); + assert.deepEqual(ranked, ["glm/fast", "qwen/recent", "glm/slow"]); + assert.deepEqual( + getModelPreferenceState("codex", { + targets: { codex: { favorites: ["glm/fast"], recent: ["qwen/recent"] } }, + }), + { favorites: ["glm/fast"], recent: ["qwen/recent"] } + ); +}); + +test("configure picker keeps preferences isolated per remote context", () => { + const preferences = { + targets: {}, + contexts: { + local: { codex: { favorites: ["local/model"], recent: [] } }, + remote: { codex: { favorites: ["remote/model"], recent: [] } }, + }, + }; + assert.deepEqual( + rankPreferredModels("codex", ["local/model", "remote/model"], preferences, "remote"), + ["remote/model", "local/model"] + ); + assert.deepEqual(getModelPreferenceState("codex", preferences, "local"), { + favorites: ["local/model"], + recent: [], + }); +}); diff --git a/tests/unit/cli/provider-crud.test.ts b/tests/unit/cli/provider-crud.test.ts new file mode 100644 index 0000000000..795a17ebf6 --- /dev/null +++ b/tests/unit/cli/provider-crud.test.ts @@ -0,0 +1,136 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + buildProviderPayload, + findConnectionFromResponse, + redactProviderResponse, + resolveProviderCredential, + runProviderAddCommand, +} from "../../../bin/cli/commands/provider-crud.mjs"; + +test("provider payload separates management auth from provider credential", () => { + const payload = buildProviderPayload( + "glm", + { + name: "work", + defaultModel: "glm/glm-5.2", + priority: "2", + providerSpecificData: '{"region":"global"}', + apiKey: "management-token-that-must-not-be-used", + }, + "provider-secret" + ); + + assert.deepEqual(payload, { + provider: "glm", + name: "work", + apiKey: "provider-secret", + defaultModel: "glm/glm-5.2", + priority: 2, + providerSpecificData: { region: "global" }, + }); +}); + +test("provider selector resolves id, prefix, name, and provider", () => { + const body = { + connections: [ + { id: "abc-123", name: "Work GLM", provider: "glm" }, + { id: "def-456", name: "OpenAI", provider: "openai" }, + ], + }; + + assert.equal(findConnectionFromResponse(body, "abc-123")?.name, "Work GLM"); + assert.equal(findConnectionFromResponse(body, "def")?.name, "OpenAI"); + assert.equal(findConnectionFromResponse(body, "work glm")?.id, "abc-123"); + assert.equal(findConnectionFromResponse(body, "openai")?.id, "def-456"); + assert.equal(findConnectionFromResponse(body, "missing"), null); +}); + +test("provider credential can be resolved from a validated environment name", async () => { + const previous = process.env.TEST_PROVIDER_SECRET; + process.env.TEST_PROVIDER_SECRET = "secret-from-env"; + try { + assert.equal( + await resolveProviderCredential({ credentialEnv: "TEST_PROVIDER_SECRET" }, { prompt: false }), + "secret-from-env" + ); + await assert.rejects( + resolveProviderCredential({ credentialEnv: "bad-name;rm" }, { prompt: false }), + /valid env name/ + ); + } finally { + if (previous === undefined) delete process.env.TEST_PROVIDER_SECRET; + else process.env.TEST_PROVIDER_SECRET = previous; + } +}); + +test("dry-run credential resolution never prompts or requires a secret", async () => { + assert.equal(await resolveProviderCredential({}, { prompt: false }), undefined); + assert.deepEqual(buildProviderPayload("glm", { name: "work" }, undefined), { + provider: "glm", + name: "work", + }); +}); + +test("negated --no-credential is treated as a control flag, not the literal string", async () => { + assert.equal( + await resolveProviderCredential({ credential: false }, { prompt: false }), + undefined + ); + assert.deepEqual(buildProviderPayload("ollama", { name: "local" }, undefined), { + provider: "ollama", + name: "local", + }); +}); + +test("provider JSON output redacts raw credentials recursively", () => { + const redacted = redactProviderResponse({ + connection: { + id: "conn-1", + apiKey: "provider-secret", + providerSpecificData: { client_secret: "oauth-secret" }, + credentialRef: "omniroute-cli:context:remote", + }, + token: "management-secret", + }); + + assert.deepEqual(redacted, { + connection: { + id: "conn-1", + apiKey: { present: true, length: 15 }, + providerSpecificData: { client_secret: { present: true, length: 12 } }, + credentialRef: "omniroute-cli:context:remote", + }, + token: { present: true, length: 17 }, + }); +}); + +test("provider OAuth dry-run never starts a browser or mutates the server", async () => { + assert.equal( + await runProviderAddCommand("openai", { oauth: true, dryRun: true, silent: true }), + 0 + ); +}); + +test("provider add dry-run redacts provider-specific secrets", async () => { + const output: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => output.push(args.join(" ")); + try { + assert.equal( + await runProviderAddCommand("glm", { + dryRun: true, + yes: true, + json: true, + providerSpecificData: JSON.stringify({ client_secret: "oauth-secret" }), + }), + 0 + ); + } finally { + console.log = originalLog; + } + const serialized = output.join("\n"); + assert.ok(!serialized.includes("oauth-secret")); + assert.match(serialized, /client_secret/); +}); diff --git a/tests/unit/cli/run-command.test.ts b/tests/unit/cli/run-command.test.ts index 7bfdd34428..aa2be59579 100644 --- a/tests/unit/cli/run-command.test.ts +++ b/tests/unit/cli/run-command.test.ts @@ -13,8 +13,10 @@ test("resolveRunTarget resolves aliases", () => { assert.equal(resolveRunTarget("CLAUDE-CODE"), "claude"); assert.equal(resolveRunTarget("cc"), "claude"); assert.equal(resolveRunTarget("codex"), "codex"); + assert.equal(resolveRunTarget("codex-cli"), "codex"); assert.equal(resolveRunTarget("openai-codex"), "codex"); assert.equal(resolveRunTarget("openai"), "codex"); + assert.equal(resolveRunTarget("anthropic"), "claude"); assert.equal(resolveRunTarget("unknown"), undefined); }); @@ -60,6 +62,78 @@ test("buildRunPlan for codex injects model into provider args", async () => { assert.equal(plan.authSource, "option"); }); +test("buildRunPlan for Aider uses its OpenAI-compatible root endpoint", async () => { + const plan = await buildRunPlan( + "aider", + { remote: "https://relay.example.test/v1", apiKey: "sk_test_x", model: "glm/glm-5.2" }, + ["--message", "reply OK"] + ); + assert.equal(plan.target, "aider"); + assert.equal(plan.baseUrl, "https://relay.example.test"); + assert.deepEqual(plan.args.slice(0, 2), ["--model", "openai/glm/glm-5.2"]); + assert.equal(plan.envDiff.changedOrAdded.includes("OPENAI_API_BASE"), true); + assert.equal(plan.envDiff.changedOrAdded.includes("OPENAI_API_KEY"), true); +}); + +test("buildRunPlan for Goose injects provider and model without writing config", async () => { + const plan = await buildRunPlan( + "goose-cli", + { baseUrl: "http://localhost:20128", apiKey: "sk_test_x", model: "glm/glm-5.2" }, + ["session"] + ); + assert.equal(plan.target, "goose"); + assert.deepEqual(plan.args, ["session"]); + assert.equal(plan.envDiff.changedOrAdded.includes("GOOSE_PROVIDER"), true); + assert.equal(plan.envDiff.changedOrAdded.includes("GOOSE_MODEL"), true); + assert.equal(plan.envDiff.changedOrAdded.includes("OPENAI_HOST"), true); +}); + +test("buildRunPlan for OpenCode uses an ephemeral compatible config", async () => { + const plan = await buildRunPlan( + "open-code", + { baseUrl: "https://relay.example.test", apiKey: "sk_test_x", model: "glm/glm-5.2" }, + ["run", "reply OK"] + ); + assert.equal(plan.target, "opencode"); + assert.deepEqual(plan.args.slice(0, 2), ["--model", "omniroute/glm/glm-5.2"]); + assert.equal(plan.envDiff.changedOrAdded.includes("OPENCODE_CONFIG_CONTENT"), true); + assert.equal(plan.envDiff.changedOrAdded.includes("OMNIROUTE_API_KEY"), true); + assert.equal(plan.configOverlay, "OPENCODE_CONFIG_CONTENT (process environment only)"); + assert.equal(JSON.stringify(plan).includes("sk_test_x"), false); +}); + +test("buildRunPlan for Qwen requires a deterministic model and injects only env names", async () => { + const plan = await buildRunPlan( + "qwen-code", + { baseUrl: "https://relay.example.test", apiKey: "sk_test_x", model: "glm/glm-5.2" }, + ["-p", "reply OK"] + ); + assert.equal(plan.target, "qwen"); + assert.deepEqual(plan.args.slice(0, 2), ["--model", "glm/glm-5.2"]); + assert.equal(plan.envDiff.changedOrAdded.includes("OMNIROUTE_API_KEY"), true); + assert.equal(plan.configOverlay, "temporary QWEN_HOME (removed after exit)"); + await assert.rejects( + () => buildRunPlan("qwen", { baseUrl: "https://relay.example.test", apiKey: "sk_test_x" }), + /requires --model/ + ); +}); + +test("buildRunPlan for Gemini points the CLI at the /v1beta surface via env", async () => { + const plan = await buildRunPlan( + "gemini-cli", + { baseUrl: "https://relay.example.test", apiKey: "sk_test_x", model: "glm/glm-5.2" }, + ["-p", "reply OK"] + ); + assert.equal(plan.target, "gemini"); + assert.equal(plan.baseUrl, "https://relay.example.test"); + assert.deepEqual(plan.args.slice(0, 2), ["--model", "glm/glm-5.2"]); + assert.equal(plan.envDiff.changedOrAdded.includes("GOOGLE_GEMINI_BASE_URL"), true); + assert.equal(plan.envDiff.changedOrAdded.includes("GEMINI_API_KEY"), true); + assert.equal(plan.envDiff.changedOrAdded.includes("GEMINI_DEFAULT_AUTH_TYPE"), true); + assert.equal(plan.configOverlay, "temporary GEMINI_CLI_HOME (removed after exit)"); + assert.equal(JSON.stringify(plan).includes("sk_test_x"), false); +}); + test("runCliTarget returns usage error code for unsupported targets", async () => { const seen = []; const originalWrite = process.stderr.write; @@ -99,3 +173,17 @@ test("dry-run --json does not print resolved auth token", async () => { const raw = chunks.join(""); assert.equal(raw.includes("sk_live_very_private_token"), false); }); + +test("--api-key-env resolves credentials without exposing their value in the plan", async () => { + const previous = process.env.OMNIROUTE_RUN_TEST_TOKEN; + process.env.OMNIROUTE_RUN_TEST_TOKEN = "sk_env_private"; + try { + const plan = await buildRunPlan("codex-cli", { apiKeyEnv: "OMNIROUTE_RUN_TEST_TOKEN" }); + assert.equal(plan.authSource, "env"); + assert.equal(plan.envDiff.changedOrAdded.includes("OMNIROUTE_API_KEY"), true); + assert.equal(JSON.stringify(plan).includes("sk_env_private"), false); + } finally { + if (previous === undefined) delete process.env.OMNIROUTE_RUN_TEST_TOKEN; + else process.env.OMNIROUTE_RUN_TEST_TOKEN = previous; + } +}); diff --git a/tests/unit/cli/run-execution.test.ts b/tests/unit/cli/run-execution.test.ts new file mode 100644 index 0000000000..85b41f4ad3 --- /dev/null +++ b/tests/unit/cli/run-execution.test.ts @@ -0,0 +1,170 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { runCliTarget } from "../../../bin/cli/commands/run.mjs"; + +const originalFetch = globalThis.fetch; +const originalPath = process.env.PATH; + +async function makeFakeCli(name: string, body: string) { + const dir = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-cli-")); + const file = path.join(dir, name); + await writeFile(file, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 }); + await chmod(file, 0o755); + return { dir, file }; +} + +async function withReachableOmniRoute(run: () => Promise): Promise { + globalThis.fetch = async () => new Response("{}", { status: 200 }); + try { + return await run(); + } finally { + globalThis.fetch = originalFetch; + } +} + +test("run executes a generic target with isolated env and propagates its exit code", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX fake executable; Windows shim behavior is covered by launch tests"); + return; + } + + const capture = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-capture-")); + const capturePath = path.join(capture, "aider.json"); + const fake = await makeFakeCli( + "aider", + `const fs = await import("node:fs"); +fs.writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({ + argv: process.argv.slice(2), + base: process.env.OPENAI_API_BASE, + key: process.env.OPENAI_API_KEY, +})); +process.exit(7);` + ); + process.env.PATH = `${fake.dir}${path.delimiter}${originalPath || ""}`; + process.env.CAPTURE_PATH = capturePath; + + try { + const code = await withReachableOmniRoute(() => + runCliTarget( + "aider", + { remote: "https://relay.example.test", apiKey: "sk_private", model: "glm/glm-5.2" }, + ["--message", "reply OK"] + ) + ); + assert.equal(code, 7); + const result = JSON.parse(await readFile(capturePath, "utf8")); + assert.deepEqual(result.argv.slice(0, 2), ["--model", "openai/glm/glm-5.2"]); + assert.deepEqual(result.argv.slice(2), ["--message", "reply OK"]); + assert.equal(result.base, "https://relay.example.test"); + assert.equal(result.key, "sk_private"); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + delete process.env.CAPTURE_PATH; + await rm(fake.dir, { recursive: true, force: true }); + await rm(capture, { recursive: true, force: true }); + } +}); + +test("run gives Gemini an isolated GEMINI_CLI_HOME forcing api-key auth and removes it", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX fake executable; Windows shim behavior is covered by launch tests"); + return; + } + + const capture = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-gemini-capture-")); + const capturePath = path.join(capture, "gemini.json"); + const fake = await makeFakeCli( + "gemini", + `const fs = await import("node:fs"); +const path = await import("node:path"); +const home = process.env.GEMINI_CLI_HOME; +const settings = JSON.parse(fs.readFileSync(path.join(home, ".gemini", "settings.json"), "utf8")); +fs.writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({ + home, + argv: process.argv.slice(2), + baseUrl: process.env.GOOGLE_GEMINI_BASE_URL, + key: process.env.GEMINI_API_KEY, + defaultAuth: process.env.GEMINI_DEFAULT_AUTH_TYPE, + selectedType: settings.security?.auth?.selectedType, +}));` + ); + process.env.PATH = `${fake.dir}${path.delimiter}${originalPath || ""}`; + process.env.CAPTURE_PATH = capturePath; + + try { + const code = await withReachableOmniRoute(() => + runCliTarget( + "gemini", + { remote: "https://relay.example.test", apiKey: "sk_private", model: "glm/glm-5.2" }, + ["-p", "reply OK"] + ) + ); + assert.equal(code, 0); + const result = JSON.parse(await readFile(capturePath, "utf8")); + assert.deepEqual(result.argv, ["--model", "glm/glm-5.2", "-p", "reply OK"]); + assert.equal(result.baseUrl, "https://relay.example.test"); + assert.equal(result.key, "sk_private"); + assert.equal(result.defaultAuth, "gemini-api-key"); + assert.equal(result.selectedType, "gemini-api-key"); + assert.equal(existsSync(result.home), false); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + delete process.env.CAPTURE_PATH; + await rm(fake.dir, { recursive: true, force: true }); + await rm(capture, { recursive: true, force: true }); + } +}); + +test("run gives Qwen an isolated temporary home and removes it after exit", async (t) => { + if (process.platform === "win32") { + t.skip("POSIX fake executable; Windows shim behavior is covered by launch tests"); + return; + } + + const capture = await mkdtemp(path.join(os.tmpdir(), "omniroute-run-qwen-capture-")); + const capturePath = path.join(capture, "qwen.json"); + const fake = await makeFakeCli( + "qwen", + `const fs = await import("node:fs"); +const path = await import("node:path"); +const home = process.env.QWEN_HOME; +const settings = JSON.parse(fs.readFileSync(path.join(home, "settings.json"), "utf8")); +fs.writeFileSync(process.env.CAPTURE_PATH, JSON.stringify({ + home, + argv: process.argv.slice(2), + model: settings.model?.name, + baseUrl: settings.model?.baseUrl, +}));` + ); + process.env.PATH = `${fake.dir}${path.delimiter}${originalPath || ""}`; + process.env.CAPTURE_PATH = capturePath; + + try { + const code = await withReachableOmniRoute(() => + runCliTarget( + "qwen", + { remote: "https://relay.example.test", apiKey: "sk_private", model: "glm/glm-5.2" }, + ["-p", "reply OK"] + ) + ); + assert.equal(code, 0); + const result = JSON.parse(await readFile(capturePath, "utf8")); + assert.deepEqual(result.argv, ["--model", "glm/glm-5.2", "-p", "reply OK"]); + assert.equal(result.model, "glm/glm-5.2"); + assert.equal(result.baseUrl, "https://relay.example.test/v1"); + assert.equal(existsSync(result.home), false); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + delete process.env.CAPTURE_PATH; + await rm(fake.dir, { recursive: true, force: true }); + await rm(capture, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/cli/setup-provider-api-key.test.ts b/tests/unit/cli/setup-provider-api-key.test.ts new file mode 100644 index 0000000000..0030a9aeb9 --- /dev/null +++ b/tests/unit/cli/setup-provider-api-key.test.ts @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Command, Option } from "commander"; + +import { mergeSetupOptions } from "../../../bin/cli/commands/setup.mjs"; + +/** + * Reproduces the flag shape of the real CLI: bin/cli/program.mjs declares a + * program-level `--api-key` (OmniRoute server key) and bin/cli/commands/setup.mjs + * declares its own `--api-key` (provider key). + */ +function parseSetupArgv(argv: string[]) { + const program = new Command(); + program.exitOverride(); + program.addOption(new Option("--api-key ", "server key").env("OMNIROUTE_API_KEY")); + program.addOption(new Option("--output ", "output").default("table")); + + let captured: Record | null = null; + program + .command("setup") + .exitOverride() + .option("--api-key ", "Provider API key") + .option("--add-provider", "Add an API-key provider connection") + .option("--provider ", "Provider id") + .option("--non-interactive", "Read all inputs from flags") + .action((opts, cmd) => { + captured = mergeSetupOptions(opts, cmd.optsWithGlobals()); + }); + + program.parse(["node", "omniroute", ...argv]); + return captured as unknown as Record; +} + +test("setup --api-key reaches runSetupCommand despite the program-level --api-key", () => { + const merged = parseSetupArgv([ + "setup", + "--non-interactive", + "--add-provider", + "--provider", + "openrouter", + "--api-key", + "sk-or-v1-example", + ]); + + // Regression: Commander binds the value to the program-level option, so the + // subcommand's own opts.apiKey is undefined and setup aborted with + // "Provider API key is required" even though --api-key was supplied. + assert.equal(merged.apiKey, "sk-or-v1-example"); + assert.equal(merged.provider, "openrouter"); + assert.equal(merged.addProvider, true); +}); + +test("OMNIROUTE_API_KEY satisfies the provider key the error message advertises", () => { + const original = process.env.OMNIROUTE_API_KEY; + process.env.OMNIROUTE_API_KEY = "sk-from-env"; + try { + const merged = parseSetupArgv(["setup", "--non-interactive", "--add-provider"]); + assert.equal(merged.apiKey, "sk-from-env"); + } finally { + if (original === undefined) delete process.env.OMNIROUTE_API_KEY; + else process.env.OMNIROUTE_API_KEY = original; + } +}); + +test("an explicit subcommand value wins over the program-level one", () => { + const merged = mergeSetupOptions( + { apiKey: "subcommand-value" }, + { apiKey: "global-value", output: "json" } + ); + assert.equal(merged.apiKey, "subcommand-value"); + assert.equal(merged.output, "json"); +}); + +test("output still comes from the program-level options", () => { + const merged = mergeSetupOptions({}, { output: "json" }); + assert.equal(merged.output, "json"); + assert.equal(merged.apiKey, undefined); +}); diff --git a/tests/unit/clinepass-provider.test.ts b/tests/unit/clinepass-provider.test.ts index d7ccdf4eb5..d49957d305 100644 --- a/tests/unit/clinepass-provider.test.ts +++ b/tests/unit/clinepass-provider.test.ts @@ -68,6 +68,7 @@ test("ClinePass fallback is the official subscription-only catalog", () => { "cline-pass/kimi-k2.7-code", "cline-pass/mimo-v2.5-pro", "cline-pass/mimo-v2.5", + "cline-pass/qwen3.8-max", "cline-pass/qwen3.7-max", "cline-pass/qwen3.7-plus", ]); diff --git a/tests/unit/cloudflare-playground-provider.test.ts b/tests/unit/cloudflare-playground-provider.test.ts new file mode 100644 index 0000000000..7031d1e71f --- /dev/null +++ b/tests/unit/cloudflare-playground-provider.test.ts @@ -0,0 +1,526 @@ +/** + * Tests for the Cloudflare AI Playground (No Auth) provider. + * + * Validates: + * - NOAUTH_PROVIDERS contains the cloudflare-playground entry (noAuth category) + * - Registry entry has correct shape (authType none), curated 20-model catalog + * - Executor resolves for both the primary id and the alias (cfp) + * - cf_agent frame → OpenAI SSE translation, exercised with REAL frames captured + * from the playground on 2026-08-15 (including decoy RPC `done:true` frames + * that must NOT terminate the chat stream, and a real 3021 rate-limit error) + * - Streaming + non-streaming responses, clean upstream errors (no stack traces) + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { NOAUTH_PROVIDERS } from "../../src/shared/constants/providers/noauth.ts"; +import { REGISTRY } from "../../open-sse/config/providers/index.ts"; +import { getExecutor } from "../../open-sse/executors/index.ts"; +import { + CloudflarePlaygroundExecutor, + CfStreamParser, + PlaywrightCfTransport, + toCfMessages, + type CfTransport, +} from "../../open-sse/executors/cloudflare-playground.ts"; + +const CHAT_ID = "chatcmpl-cfp-test123"; + +// ── Fixtures: REAL frames captured from the playground (2026-08-15) ───────── + +const identityFrame = JSON.stringify({ + name: "playground-8d57d26b34b144108fd1f49d2", + agent: "playground", + type: "cf_agent_identity", +}); +const stateFrame = JSON.stringify({ + state: { + model: "@cf/zai-org/glm-4.7-flash", + temperature: 1, + stream: true, + system: "You are a helpful assistant.", + }, + type: "cf_agent_state", +}); +/** Decoy: the setConfig RPC response also carries `done:true` — must be ignored. */ +const decoyRpcDone = JSON.stringify({ + id: "cfp-config", + done: true, + type: "cf_agent_rpc_response", +}); + +const cfFrame = (chatId: string, body: unknown) => + JSON.stringify({ id: chatId, type: "cf_agent_use_chat_response", body: JSON.stringify(body) }); + +/** Full success stream built for a given chat id. */ +const buildSuccessFrames = (chatId: string) => [ + identityFrame, + stateFrame, + decoyRpcDone, + cfFrame(chatId, { type: "start" }), + cfFrame(chatId, { type: "start-step" }), + cfFrame(chatId, { type: "reasoning-start", id: "r1" }), + cfFrame(chatId, { type: "reasoning-delta", delta: "thinking about it...", id: "r1" }), + cfFrame(chatId, { type: "reasoning-end", id: "r1" }), + cfFrame(chatId, { type: "text-start", id: "t1" }), + cfFrame(chatId, { type: "text-delta", delta: "Hello ", id: "t1" }), + cfFrame(chatId, { type: "text-delta", delta: "world!", id: "t1" }), + cfFrame(chatId, { type: "finish-step" }), + cfFrame(chatId, { type: "finish", messageMetadata: { finishReason: "stop" } }), + JSON.stringify({ id: chatId, type: "cf_agent_use_chat_response", done: true }), +]; + +/** Real rate-limit error frame (kimi-k2.6, captured live), built for a chat id. */ +const buildRateLimitFrame = (chatId: string) => + JSON.stringify({ + error: true, + body: JSON.stringify({ + message: "The model is currently rate limited. Please wait a moment and try again.", + details: "3021: rate limiting: inference request per min rate reached", + }), + done: false, + id: chatId, + type: "cf_agent_use_chat_response", + }); + +class FakeTransport implements CfTransport { + constructor( + private framesList: string[], + private fail: { status: number; message: string } | null = null + ) {} + + async start(): Promise<{ ok: true } | { ok: false; status: number; message: string }> { + return this.fail ? { ok: false, ...this.fail } : { ok: true }; + } + + async *frames(): AsyncGenerator { + for (const frame of this.framesList) yield frame; + } + + async close(): Promise {} +} + +function makeExecutor( + buildFrames: (chatId: string) => string[], + fail?: { status: number; message: string } +) { + return new CloudflarePlaygroundExecutor((chatId) => new FakeTransport(buildFrames(chatId), fail)); +} + +const executeArgs = (body: Record, stream: boolean) => + ({ body, credentials: {}, signal: null, stream }) as unknown as Parameters< + CloudflarePlaygroundExecutor["execute"] + >[0]; + +// ── Catalog / NOAUTH_PROVIDERS ─────────────────────────────────────────────── + +test("cloudflare-playground is present in NOAUTH_PROVIDERS (noAuth category)", () => { + const p = (NOAUTH_PROVIDERS as Record)["cloudflare-playground"] as Record< + string, + unknown + >; + assert.ok(p, "NOAUTH_PROVIDERS['cloudflare-playground'] must exist"); + assert.equal(p.id, "cloudflare-playground"); + assert.equal(p.alias, "cfp"); + assert.equal((p.name as string).includes("Cloudflare"), true); + assert.equal(p.noAuth, true); + assert.equal(p.hasFree, true); + assert.ok(typeof p.freeNote === "string" && (p.freeNote as string).length > 0); + assert.ok(typeof p.authHint === "string" && (p.authHint as string).length > 0); + assert.ok(typeof p.website === "string" && (p.website as string).includes("cloudflare.com")); +}); + +test("cloudflare-playground registry entry has no-auth shape and curated models", () => { + const entry = REGISTRY["cloudflare-playground"]; + assert.ok(entry, "REGISTRY['cloudflare-playground'] must exist"); + assert.equal(entry.alias, "cfp"); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "cloudflare-playground"); + assert.equal(entry.authType, "none"); + assert.equal(entry.authHeader, "none"); + assert.equal(entry.baseUrl, "https://playground.ai.cloudflare.com"); + + assert.ok( + entry.models.length >= 15, + `expected a curated catalog, got ${entry.models.length} models` + ); + // No model id carries the upstream @cf/ prefix (executor adds it). + for (const model of entry.models) { + assert.ok(!model.id.startsWith("@cf/"), `model id must be prefix-free: ${model.id}`); + } + // Flagships present. + const ids = new Set(entry.models.map((m) => m.id)); + for (const expected of [ + "zai-org/glm-5.2", + "moonshotai/kimi-k2.6", + "deepseek-ai/deepseek-v4-flash-0731", + "openai/gpt-oss-120b", + "qwen/qwen2.5-coder-32b-instruct", + ]) { + assert.ok(ids.has(expected), `expected model ${expected} in catalog`); + } + // Reasoning flags on the known thinking models. + const glm = entry.models.find((m) => m.id === "zai-org/glm-5.2"); + assert.equal(glm?.supportsReasoning, true); + const llama = entry.models.find((m) => m.id === "meta-llama/llama-3.3-70b-instruct-fp8-fast"); + assert.equal(llama?.supportsReasoning, undefined); +}); + +test("executor resolves for both the id and the cfp alias", () => { + const byId = getExecutor("cloudflare-playground"); + const byAlias = getExecutor("cfp"); + assert.ok(byId instanceof CloudflarePlaygroundExecutor); + assert.ok(byAlias instanceof CloudflarePlaygroundExecutor); +}); + +// ── Frame → SSE translation (real captured traffic) ───────────────────────── + +test("CfStreamParser translates a real captured stream (decoys ignored)", () => { + const parser = new CfStreamParser(CHAT_ID); + let events = 0; + for (const frame of buildSuccessFrames(CHAT_ID)) { + const event = parser.push(frame); + if (event) events += 1; + } + assert.equal(parser.text, "Hello world!"); + assert.equal(parser.reasoningText, "thinking about it..."); + assert.equal(parser.finishReason, "stop"); + assert.equal(parser.done, true); + assert.equal(parser.error, null); + // role + 1 reasoning + 2 content + 1 finish + assert.equal(events, 5); +}); + +test("CfStreamParser ignores done:true frames that belong to other ids/RPCs", () => { + const parser = new CfStreamParser(CHAT_ID); + // Decoy RPC response with done:true + parser.push(decoyRpcDone); + assert.equal(parser.done, false, "RPC done:true must not end the chat stream"); + // A chat-response frame for a DIFFERENT chat id + parser.push( + JSON.stringify({ id: "chatcmpl-OTHER", type: "cf_agent_use_chat_response", done: true }) + ); + assert.equal(parser.done, false, "foreign chat id must not end the stream"); + // The real one + parser.push(JSON.stringify({ id: CHAT_ID, type: "cf_agent_use_chat_response", done: true })); + assert.equal(parser.done, true); +}); + +test("CfStreamParser maps the real 3021 rate-limit frame to HTTP 429", () => { + const parser = new CfStreamParser(CHAT_ID); + parser.push(buildRateLimitFrame(CHAT_ID)); + assert.ok(parser.error, "rate-limit frame must surface as an error"); + assert.equal(parser.error?.status, 429); + assert.ok((parser.error?.message ?? "").includes("rate limiting")); + assert.equal(parser.done, false); +}); + +test("toCfMessages drops system/tool, flattens parts, keeps user/assistant", () => { + const out = toCfMessages([ + { role: "system", content: "You are a helpful assistant." }, + { role: "user", content: "hi" }, + { role: "assistant", content: [{ type: "text", text: "hello" }] }, + { + role: "user", + content: [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ], + }, + { role: "tool", content: "tool result" }, + { role: "user", content: "" }, + ]); + assert.equal(out.length, 3); + assert.deepEqual(out[0].parts, [{ type: "text", text: "hi" }]); + assert.equal(out[1].parts[0].text, "hello"); + assert.equal(out[2].parts[0].text, "a\nb"); + assert.equal(out[0].role, "user"); + assert.equal(out[1].role, "assistant"); +}); + +// ── Executor behavior (fake transport, real frames) ───────────────────────── + +test("executor streams OpenAI SSE chunks from captured frames", async () => { + const executor = makeExecutor(buildSuccessFrames); + const result = await executor.execute( + executeArgs( + { model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] }, + true + ) + ); + const response = result.response; + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") ?? "", /text\/event-stream/); + + const raw = await response.text(); + assert.ok(raw.endsWith("data: [DONE]\n\n"), "stream must end with [DONE]"); + + const chunks = raw + .split("\n") + .filter((line) => line.startsWith("data: ") && line !== "data: [DONE]") + .map((line) => JSON.parse(line.slice(6))); + assert.ok(chunks.length >= 5, `expected several chunks, got ${chunks.length}`); + + const first = chunks[0]; + assert.equal(first.choices[0].delta.role, "assistant"); + assert.equal(first.choices[0].finish_reason, null); + + const reasoningChunk = chunks.find((c) => c.choices?.[0]?.delta?.reasoning_content); + assert.equal(reasoningChunk?.choices?.[0]?.delta?.reasoning_content, "thinking about it..."); + + const content = chunks + .filter((c) => c.choices?.[0]?.delta?.content) + .map((c) => c.choices[0].delta.content) + .join(""); + assert.equal(content, "Hello world!"); + + const last = chunks[chunks.length - 1]; + assert.equal(last.choices[0].finish_reason, "stop"); + assert.equal(last.model, "zai-org/glm-4.7-flash"); +}); + +test("executor returns JSON for non-streaming requests", async () => { + const executor = makeExecutor(buildSuccessFrames); + const result = await executor.execute( + executeArgs( + { model: "moonshotai/kimi-k2.6", messages: [{ role: "user", content: "hi" }] }, + false + ) + ); + const response = result.response; + assert.equal(response.status, 200); + assert.match(response.headers.get("content-type") ?? "", /application\/json/); + + const parsed = JSON.parse(await response.text()) as { + choices: Array<{ + message: { content: string; reasoning_content?: string }; + finish_reason: string; + }>; + model: string; + }; + assert.equal(parsed.choices[0].message.content, "Hello world!"); + assert.equal(parsed.choices[0].message.reasoning_content, "thinking about it..."); + assert.equal(parsed.choices[0].finish_reason, "stop"); + assert.equal(parsed.model, "moonshotai/kimi-k2.6"); +}); + +test("executor surfaces the 3021 rate limit as a clean 429 (no stack traces)", async () => { + const executor = makeExecutor((chatId) => [buildRateLimitFrame(chatId)]); + const result = await executor.execute( + executeArgs( + { model: "moonshotai/kimi-k2.6", messages: [{ role: "user", content: "hi" }] }, + false + ) + ); + assert.equal(result.response.status, 429); + const parsed = JSON.parse(await result.response.text()) as { + error: { message: string; type: string }; + }; + assert.ok(parsed.error.message.includes("rate limiting")); + assert.equal(parsed.error.type, "upstream_error"); + assert.ok(!parsed.error.message.includes(" at "), "no stack-trace leak"); +}); + +test("executor returns a clean 502 when the browser session cannot start", async () => { + const executor = makeExecutor(buildSuccessFrames, { + status: 502, + message: "Cloudflare Playground browser session failed: boom", + }); + const result = await executor.execute( + executeArgs( + { model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] }, + true + ) + ); + assert.equal(result.response.status, 502); + const parsed = JSON.parse(await result.response.text()) as { error: { message: string } }; + assert.ok(parsed.error.message.includes("browser session failed")); + assert.ok(!parsed.error.message.includes(" at "), "no stack-trace leak"); +}); + +test("executor prefixes bare model ids with @cf/ (upstream convention)", async () => { + const seen: string[] = []; + class CapturingTransport extends FakeTransport { + async start(config: Parameters[0]) { + seen.push(config.model); + return { ok: true } as const; + } + } + const executor = new CloudflarePlaygroundExecutor( + (chatId) => new CapturingTransport(buildSuccessFrames(chatId)) + ); + await executor.execute( + executeArgs( + { model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] }, + false + ) + ); + assert.equal(seen.length, 1); + assert.equal(seen[0], "@cf/zai-org/glm-4.7-flash"); +}); + +// ── #10494: browser/transport resource leak on blocked-request paths ─────── + +test("PlaywrightCfTransport.start() closes the browser when Cloudflare Attention Required is detected", async () => { + const playwright = await import("playwright"); + const originalLaunch = playwright.chromium.launch; + let closeCalls = 0; + + playwright.chromium.launch = (async () => + ({ + newContext: async () => ({ + newPage: async () => ({ + goto: async () => {}, + title: async () => "Attention Required! | Cloudflare", + exposeFunction: async () => {}, + evaluate: async () => {}, + }), + }), + close: async () => { + closeCalls += 1; + }, + }) as unknown as ReturnType) as typeof playwright.chromium.launch; + + try { + const transport = new PlaywrightCfTransport("chat-attention-required"); + const started = await transport.start({ + model: "@cf/test-model", + messages: [], + temperature: 0.7, + }); + assert.equal(started.ok, false); + if (started.ok === false) { + assert.equal(started.status, 502); + } + assert.equal(closeCalls, 1, "browser launched for the challenge check must be closed"); + } finally { + playwright.chromium.launch = originalLaunch; + } +}); + +// ── #10494: streaming timeout must not be misreported as a clean [DONE] ──── + +/** + * A transport whose frames() hangs (never yields) once its initial queue is + * drained, mirroring PlaywrightCfTransport's real behavior: frames() only + * resolves again once close() is called (real close() unblocks pending + * waiters with null, ending the generator). This lets tests force the + * executor's internal chat-timeout branch deterministically instead of + * waiting for CHAT_TIMEOUT_MS. + */ +class HangingTransport implements CfTransport { + closeCalls = 0; + private closed = false; + private queue: string[]; + private waiters: Array<(frame: string | null) => void> = []; + + constructor(initialFrames: string[] = []) { + this.queue = [...initialFrames]; + } + + async start(): Promise<{ ok: true } | { ok: false; status: number; message: string }> { + return { ok: true }; + } + + async *frames(): AsyncGenerator { + while (true) { + if (this.queue.length > 0) { + yield this.queue.shift()!; + continue; + } + const frame = await new Promise((resolve) => this.waiters.push(resolve)); + if (frame === null) return; + yield frame; + } + } + + // Idempotent, mirroring PlaywrightCfTransport.close(): the timer callback + // and the streaming finally block both call close() on the timeout path. + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.closeCalls += 1; + for (const waiter of this.waiters.splice(0)) waiter(null); + } +} + +function parseSseChunks(raw: string) { + return raw + .split("\n\n") + .filter((chunk) => chunk.startsWith("data: ") && chunk !== "data: [DONE]") + .map((chunk) => JSON.parse(chunk.slice(6))); +} + +test("streaming: an empty timeout (no frames at all) emits an explicit error chunk, not a bare [DONE]", async () => { + const transport = new HangingTransport([]); + const executor = new CloudflarePlaygroundExecutor(() => transport, 20); + const result = await executor.execute( + executeArgs( + { model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] }, + true + ) + ); + const raw = await result.response.text(); + assert.ok(raw.endsWith("data: [DONE]\n\n"), "stream must still end with [DONE]"); + assert.equal(transport.closeCalls, 1, "timed-out transport must be closed"); + + const chunks = parseSseChunks(raw); + assert.ok(chunks.length >= 1, "an error chunk must be emitted before [DONE]"); + const errorChunk = chunks.find((c) => c.error); + assert.ok(errorChunk, "expected an explicit error chunk on timeout"); + assert.equal(errorChunk.error.type, "timeout_error"); + assert.equal(errorChunk.error.code, "HTTP_504"); + assert.ok(!errorChunk.error.message.includes(" at "), "no stack-trace leak"); +}); + +test("streaming: a partial answer followed by a timeout emits content THEN an explicit error chunk", async () => { + // The executor mints its own random chat id (chatcmpl-cfp-) and only + // the transportFactory receives it — frames must reference that same id or + // CfStreamParser silently ignores them (see `msg.id !== this.chatId` + // above). Build the partial frames from the factory callback, exactly like + // buildSuccessFrames()/makeExecutor() do above. + let transport!: HangingTransport; + const executor = new CloudflarePlaygroundExecutor((chatId) => { + const partialFrames = [ + JSON.stringify({ + id: chatId, + type: "cf_agent_use_chat_response", + body: JSON.stringify({ type: "start" }), + }), + JSON.stringify({ + id: chatId, + type: "cf_agent_use_chat_response", + body: JSON.stringify({ type: "text-delta", delta: "Hello", id: "t1" }), + }), + ]; + transport = new HangingTransport(partialFrames); + return transport; + }, 20); + const result = await executor.execute( + executeArgs( + { model: "zai-org/glm-4.7-flash", messages: [{ role: "user", content: "hi" }] }, + true + ) + ); + const raw = await result.response.text(); + assert.ok(raw.endsWith("data: [DONE]\n\n")); + assert.equal(transport.closeCalls, 1); + + const chunks = parseSseChunks(raw); + const content = chunks + .filter((c) => c.choices?.[0]?.delta?.content) + .map((c) => c.choices[0].delta.content) + .join(""); + assert.equal(content, "Hello", "the partial content already streamed must not be dropped"); + + const errorChunk = chunks.find((c) => c.error); + assert.ok(errorChunk, "a partial-then-timeout stream must still surface an explicit error"); + assert.equal(errorChunk.error.type, "timeout_error"); + + // The error chunk must come after the content, so a client processing the + // stream in order sees the partial answer followed by a clear failure — + // never a silent, successful-looking [DONE] right after partial content. + const errorIndex = chunks.indexOf(errorChunk); + const lastContentIndex = chunks.findLastIndex((c) => c.choices?.[0]?.delta?.content); + assert.ok(errorIndex > lastContentIndex, "error chunk must follow the streamed content"); +}); diff --git a/tests/unit/codex-gpt56-catalog.test.ts b/tests/unit/codex-gpt56-catalog.test.ts index 8cdfbe4dd7..b4eb0ab293 100644 --- a/tests/unit/codex-gpt56-catalog.test.ts +++ b/tests/unit/codex-gpt56-catalog.test.ts @@ -36,8 +36,8 @@ test("Codex catalog exposes the GPT-5.6 lineup in configured priority order", () for (const modelId of expectedIds) { const model = models.find((entry) => entry.id === modelId); assert.ok(model, `codex must expose ${modelId}`); - assert.equal(model.contextLength, 1050000); - assert.equal(model.maxInputTokens, 922000); + assert.equal(model.contextLength, 272000); + assert.equal(model.maxInputTokens, 272000); assert.equal(model.maxOutputTokens, 128000); assert.equal(model.targetFormat, "openai-responses"); assert.equal(model.toolCalling, true); diff --git a/tests/unit/codex-tool-handoff-disconnect-499.test.ts b/tests/unit/codex-tool-handoff-disconnect-499.test.ts new file mode 100644 index 0000000000..9514345e92 --- /dev/null +++ b/tests/unit/codex-tool-handoff-disconnect-499.test.ts @@ -0,0 +1,377 @@ +/** + * Regression coverage for Codex Responses tool handoffs: Codex can close the + * current HTTP response immediately after receiving a complete tool-call item, + * before the trailing response.completed frame reaches the client. OmniRoute + * must keep the upstream transform alive briefly so its normal completion and + * usage bookkeeping can still win over the delayed 499 finalizer. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { FORMATS } from "../../open-sse/translator/formats.ts"; +import { createCompletedResponsesToolHandoffWatcher } from "../../open-sse/utils/responsesToolHandoff.ts"; +import { createPassthroughStreamWithLogger } from "../../open-sse/utils/stream.ts"; +import { + createDisconnectAwareStream, + createNoopAbortWritable, + createStreamController, +} from "../../open-sse/utils/streamHandler.ts"; +import { createClientDisconnectGraceHandler } from "../../open-sse/utils/streamFailureFinalization.ts"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function sse(event: string, data: Record): string { + return `event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`; +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function cancelAfterFirstChunk({ + sseText, + allowCompletedToolHandoffGrace = true, +}: { + sseText: string; + allowCompletedToolHandoffGrace?: boolean; +}): Promise<{ upstreamCancelled: boolean; disconnects: number; signalAborted: boolean }> { + let upstreamCancelled = false; + let disconnects = 0; + const transformStream = { + readable: new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sseText)); + }, + cancel() { + upstreamCancelled = true; + }, + }), + writable: createNoopAbortWritable(), + }; + const streamController = createStreamController({ + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + allowCompletedToolHandoffGrace, + clientDisconnectGracePeriodMs: 50, + onDisconnect: () => { + disconnects++; + }, + }); + const clientStream = createDisconnectAwareStream(transformStream, streamController); + const reader = clientStream.getReader(); + assert.equal((await reader.read()).done, false); + await reader.cancel("request_signal_aborted"); + + return { + upstreamCancelled, + disconnects, + signalAborted: streamController.signal.aborted, + }; +} + +test("Codex tool handoff drains the trailing Responses completion instead of persisting 499", async () => { + let upstreamController: ReadableStreamDefaultController | null = null; + let upstreamCancelled = false; + let completionRecorded = false; + let completionStatus: number | null = null; + let disconnectFinalizedAs499 = false; + const clientAbortController = new AbortController(); + + const providerStream = new ReadableStream({ + start(controller) { + upstreamController = controller; + controller.enqueue( + encoder.encode( + sse("response.output_item.added", { + output_index: 0, + item: { + id: "ctc_1", + type: "custom_tool_call", + call_id: "call_1", + name: "apply_patch", + input: "", + status: "in_progress", + }, + }) + + sse("response.custom_tool_call_input.done", { + item_id: "ctc_1", + output_index: 0, + input: "*** Begin Patch\n*** End Patch", + }) + + sse("response.output_item.done", { + output_index: 0, + item: { + id: "ctc_1", + type: "custom_tool_call", + call_id: "call_1", + name: "apply_patch", + input: "*** Begin Patch\n*** End Patch", + status: "completed", + }, + }) + ) + ); + }, + cancel() { + upstreamCancelled = true; + }, + }); + + const disconnectGraceHandler = createClientDisconnectGraceHandler({ + isStreamCompletionRecorded: () => completionRecorded, + gracePeriodMs: 50, + pollIntervalMs: 5, + finalize: () => { + disconnectFinalizedAs499 = true; + completionStatus = 499; + }, + }); + const streamController = createStreamController({ + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + allowCompletedToolHandoffGrace: true, + clientDisconnectGracePeriodMs: 50, + clientAbortSignal: clientAbortController.signal, + onDisconnect: disconnectGraceHandler, + }); + const transformStream = createPassthroughStreamWithLogger( + "codex", + null, + null, + "gpt-5.6-sol", + "connection-1", + { model: "gpt-5.6-sol", stream: true }, + (payload) => { + completionRecorded = true; + completionStatus = payload.status; + }, + null, + null, + FORMATS.OPENAI_RESPONSES + ); + const transformedBody = providerStream.pipeThrough(transformStream); + const clientStream = createDisconnectAwareStream( + { readable: transformedBody, writable: createNoopAbortWritable() }, + streamController + ); + const reader = clientStream.getReader(); + + let received = ""; + while (!received.includes("response.output_item.done")) { + const chunk = await reader.read(); + assert.equal(chunk.done, false); + received += decoder.decode(chunk.value, { stream: true }); + } + + clientAbortController.abort("request_signal_aborted"); + const cancelPromise = reader.cancel("request_signal_aborted"); + setTimeout(() => { + try { + upstreamController?.enqueue( + encoder.encode( + sse("response.completed", { + response: { + id: "resp_1", + status: "completed", + output: [ + { + id: "ctc_1", + type: "custom_tool_call", + call_id: "call_1", + name: "apply_patch", + input: "*** Begin Patch\n*** End Patch", + status: "completed", + }, + ], + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }, + }) + ) + ); + upstreamController?.close(); + } catch { + // The unchanged implementation cancels the upstream before this trailing + // completion can arrive; the assertions below expose that regression. + } + }, 0); + + await cancelPromise; + await wait(70); + + assert.equal(upstreamCancelled, false, "the completed tool handoff must be drained, not aborted"); + assert.equal(disconnectFinalizedAs499, false, "the real completion must beat the 499 finalizer"); + assert.equal(completionRecorded, true); + assert.equal(completionStatus, 200); +}); + +test("Codex handoff grace does not apply to an incomplete custom tool call", async () => { + const result = await cancelAfterFirstChunk({ + sseText: sse("response.output_item.done", { + output_index: 0, + item: { + id: "ctc_1", + type: "custom_tool_call", + call_id: "call_1", + name: "apply_patch", + input: "partial", + status: "completed", + }, + }), + }); + + assert.deepEqual(result, { upstreamCancelled: true, disconnects: 1, signalAborted: true }); +}); + +test("Codex handoff detection accepts a complete function call split across SSE chunks", () => { + const watcher = createCompletedResponsesToolHandoffWatcher(); + const frames = + sse("response.function_call_arguments.done", { + item_id: "fc_1", + output_index: 0, + arguments: '{"path":"README.md"}', + }) + + sse("response.output_item.done", { + output_index: 0, + item: { + id: "fc_1", + type: "function_call", + call_id: "call_1", + name: "read_file", + arguments: '{"path":"README.md"}', + status: "completed", + }, + }); + const splitAt = frames.indexOf("response.output_item.done") + 9; + + assert.equal(watcher.note(frames.slice(0, splitAt)), false); + assert.equal(watcher.note(frames.slice(splitAt)), true); +}); + +test("Codex handoff grace requires matching done input and completed item payloads", async () => { + const result = await cancelAfterFirstChunk({ + sseText: + sse("response.custom_tool_call_input.done", { + item_id: "ctc_1", + output_index: 0, + input: "complete input", + }) + + sse("response.output_item.done", { + output_index: 0, + item: { + id: "ctc_1", + type: "custom_tool_call", + call_id: "call_1", + name: "apply_patch", + input: "different input", + status: "completed", + }, + }), + }); + + assert.deepEqual(result, { upstreamCancelled: true, disconnects: 1, signalAborted: true }); +}); + +test("completed tool calls from non-Codex Responses clients keep normal abort behavior", async () => { + const result = await cancelAfterFirstChunk({ + allowCompletedToolHandoffGrace: false, + sseText: + sse("response.function_call_arguments.done", { + item_id: "fc_1", + output_index: 0, + arguments: "{}", + }) + + sse("response.output_item.done", { + output_index: 0, + item: { + id: "fc_1", + type: "function_call", + call_id: "call_1", + name: "read_file", + arguments: "{}", + status: "completed", + }, + }), + }); + + assert.deepEqual(result, { upstreamCancelled: true, disconnects: 1, signalAborted: true }); +}); + +test("Codex handoff grace still finalizes 499 and aborts when no completion arrives", async () => { + let upstreamCancelled = false; + let finalizedAs499 = false; + let completionRecorded = false; + const clientAbortController = new AbortController(); + const providerStream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + sse("response.custom_tool_call_input.done", { + item_id: "ctc_1", + output_index: 0, + input: "complete input", + }) + + sse("response.output_item.done", { + output_index: 0, + item: { + id: "ctc_1", + type: "custom_tool_call", + call_id: "call_1", + name: "apply_patch", + input: "complete input", + status: "completed", + }, + }) + ) + ); + }, + cancel() { + upstreamCancelled = true; + }, + }); + const disconnectGraceHandler = createClientDisconnectGraceHandler({ + isStreamCompletionRecorded: () => completionRecorded, + gracePeriodMs: 25, + pollIntervalMs: 5, + finalize: () => { + finalizedAs499 = true; + }, + }); + const streamController = createStreamController({ + clientResponseFormat: FORMATS.OPENAI_RESPONSES, + allowCompletedToolHandoffGrace: true, + clientDisconnectGracePeriodMs: 25, + clientAbortSignal: clientAbortController.signal, + onDisconnect: disconnectGraceHandler, + }); + const transformStream = createPassthroughStreamWithLogger( + "codex", + null, + null, + "gpt-5.6-sol", + "connection-1", + { model: "gpt-5.6-sol", stream: true }, + () => { + completionRecorded = true; + }, + null, + null, + FORMATS.OPENAI_RESPONSES + ); + const clientStream = createDisconnectAwareStream( + { + readable: providerStream.pipeThrough(transformStream), + writable: createNoopAbortWritable(), + }, + streamController + ); + const reader = clientStream.getReader(); + assert.equal((await reader.read()).done, false); + + clientAbortController.abort("request_signal_aborted"); + await reader.cancel("request_signal_aborted"); + await wait(60); + + assert.equal(completionRecorded, false); + assert.equal(finalizedAs499, true); + assert.equal(upstreamCancelled, true); + assert.equal(streamController.signal.aborted, true); +}); diff --git a/tests/unit/codex-ws-policy-enforcement-6564.test.ts b/tests/unit/codex-ws-policy-enforcement-6564.test.ts index 3214a0c274..7801e27a9f 100644 --- a/tests/unit/codex-ws-policy-enforcement-6564.test.ts +++ b/tests/unit/codex-ws-policy-enforcement-6564.test.ts @@ -154,6 +154,25 @@ test("WS prepare() allows the requested model when the key's policy permits it ( assert.equal(body.error?.code, "codex_credentials_unavailable"); }); +test("WS prepare() rejects managed lease keys before credential selection", async () => { + const managedKey = await apiKeysDb.createApiKey( + "Managed Lease WS Key", + "machine-lease-ws", + ["lease:exclusive"], + { allowedConnections: ["synthetic-managed-connection"] } + ); + await apiKeysDb.updateApiKeyPermissions(managedKey.id, { + allowedModels: ["gpt-5.5"], + }); + + const response = await route.POST(buildPrepareRequest(managedKey.key, "gpt-5.5")); + const body = (await response.json()) as ErrorBody; + + assert.equal(response.status, 409); + assert.equal(body.error.code, "LEASE_UNSUPPORTED_TRANSPORT"); + assert.notEqual(body.error.code, "codex_credentials_unavailable"); +}); + test("WS prepare() rejects a combo not in the key's allowedCombos policy (403)", async () => { await combosDb.createCombo({ name: "model-1.0", diff --git a/tests/unit/combo-antigravity-missing-project-reset-8486.test.ts b/tests/unit/combo-antigravity-missing-project-reset-8486.test.ts index b645d309ac..f3fcf9ca97 100644 --- a/tests/unit/combo-antigravity-missing-project-reset-8486.test.ts +++ b/tests/unit/combo-antigravity-missing-project-reset-8486.test.ts @@ -66,7 +66,18 @@ async function runScenario(models: string[]) { return { result, modelsCalled }; } -test("#8486 Part B: combo unavailableResponse must not attach an unrelated target's long retryAfter to the antigravity missing-projectId 422", async () => { +// #10314/#10501 superseded the original "one target's message wins, silently +// drops the sibling's reason" contract these two tests pinned: combo terminal +// aggregation now DELIBERATELY lists every distinct per-target reason (#10314) +// and normalizes a heterogeneous failure mix to a 5xx-class status instead of a +// bare `lastStatus` (#10501 — see comboErrorAggregation.ts::resolveComboTerminalStatus). +// The underlying #8486 concern — a config-class error getting the WRONG target's +// long retry-after window stitched onto it — is still the thing under test, just +// verified against the new contract: the response's `Retry-After` HEADER (the +// actual out-of-band decoration #8486 was about) must never carry the unrelated +// 21h47m window, in EITHER attempt order, regardless of which reasons appear in +// the (now intentionally multi-reason) message body. +test("#8486 Part B: heterogeneous rate_limit+config-class antigravity failure never attaches the unrelated 21h47m retryAfter as a response header", async () => { const { result, modelsCalled } = await runScenario([ "antigravity/account-a-model", "antigravity/account-b-model", @@ -78,17 +89,24 @@ test("#8486 Part B: combo unavailableResponse must not attach an unrelated targe `expected both targets to be tried, got: ${JSON.stringify(modelsCalled)}` ); - const text = await result.clone().text(); - - assert.ok( - !/reset after/i.test(text) || !/missing google projectid/i.test(text), - "a config-class antigravity error (missing_project_id, no retryAfter of its own) " + - "must not be decorated with an unrelated target's long retry-after window — " + - `got body: ${text}` + // #10501: neither target's failure alone proves the CLIENT's request was + // invalid (one is a rate limit, the other a config/auth problem) — the + // heterogeneous mix must normalize to a 5xx infra/provider status. + assert.equal(result.status, 502); + assert.equal( + result.headers.get("Retry-After"), + null, + "the config-class 422 (no retryAfter of its own) must never end up decorated " + + "with account-a's unrelated 21h47m retry-after header" ); + + // #10314: both distinct reasons are now surfaced (never silently dropped). + const text = await result.clone().text(); + assert.match(text, /reset after 21h47m32s/i); + assert.match(text, /missing google projectid/i); }); -test("#8486 Part B (reverse order): the config-class 422 must not swallow a genuinely rate-limited sibling's message either", async () => { +test("#8486 Part B (reverse order): same result independent of which target failed first — both reasons present, no bogus Retry-After header", async () => { const { result, modelsCalled } = await runScenario([ "antigravity/account-b-model", "antigravity/account-a-model", @@ -100,14 +118,10 @@ test("#8486 Part B (reverse order): the config-class 422 must not swallow a genu `expected both targets to be tried, got: ${JSON.stringify(modelsCalled)}` ); - const text = await result.clone().text(); + assert.equal(result.status, 502, "attempt order must not change the terminal status"); + assert.equal(result.headers.get("Retry-After"), null); - // The surfaced status/message pair must always originate from the SAME - // (last-attempted) target: here that's account-a (429, real retryAfter), - // so the response must carry ITS message and MAY carry its own retry-after - // — but must never resurrect the unrelated account-b 422 text alongside it. - assert.ok( - !/missing google projectid/i.test(text), - `expected the last target's (account-a, 429) own message, not the unrelated account-b 422 text — got body: ${text}` - ); + const text = await result.clone().text(); + assert.match(text, /reset after 21h47m32s/i); + assert.match(text, /missing google projectid/i); }); diff --git a/tests/unit/combo-builder-effort-variants-8072.test.ts b/tests/unit/combo-builder-effort-variants-8072.test.ts index 7333071c3c..86db44fdc0 100644 --- a/tests/unit/combo-builder-effort-variants-8072.test.ts +++ b/tests/unit/combo-builder-effort-variants-8072.test.ts @@ -141,6 +141,7 @@ test("#9485 static DeepSeek effort aliases appear when synced rows omit supporte `${flashId}-high`, `${flashId}-max`, `${proId}-none`, + `${proId}-low`, `${proId}-high`, `${proId}-max`, ]); @@ -150,10 +151,6 @@ test("#9485 static DeepSeek effort aliases appear when synced rows omit supporte .filter((id) => id.startsWith(`${flashId}-`) || id.startsWith(`${proId}-`)) ); assert.deepEqual(deepSeekAliases, expectedAliases); - assert.equal( - provider!.models.some((model) => model.id === `${proId}-low`), - false - ); assert.equal( provider!.models.some((model) => model.id === `${proId}-medium`), false diff --git a/tests/unit/combo-context-overflow-compression-probe.test.ts b/tests/unit/combo-context-overflow-compression-probe.test.ts new file mode 100644 index 0000000000..f482cba352 --- /dev/null +++ b/tests/unit/combo-context-overflow-compression-probe.test.ts @@ -0,0 +1,409 @@ +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"; + +/** + * #10225 — combo known-context-overflow must NOT hard-reject a compressible + * request before OmniRoute's compression pipeline can run. + * + * Root cause: getKnownContextOverflow() estimates the RAW body (ceil(serializedChars/4) + * over the whole Responses input[]) during combo target resolution, before any + * compression. When every known target limit is below that raw estimate, both call + * sites (round-robin + target-resolution) convert it into an immediate local 400 + * `context_length_exceeded` with attempted:0 — so chatCore's proactive compression + * (which can shrink 294133→111529, 62% in the reporter's case) never runs. The only + * existing bypass (clientManagedResponsesContext) is gated to VERIFIED native Codex + * clients, so a generic Responses client (e.g. OpenCode) pointed at a codex model + * still hits the hard gate. + * + * Fix: thread a request-scoped `deferContextOverflowWhenCompressible` flag (set when + * the global compression switch is ON and not API-key opted-out). When set AND at + * least one target can run compression, getKnownContextOverflow returns null so the + * request reaches chatCore, whose post-compression enforceOutputTokenBudget becomes + * the final context gate — a local 400 only if the compressed body still cannot fit. + */ + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-overflow-compress-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +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 { getKnownContextOverflow, handleComboChat } = await import( + "../../open-sse/services/combo.ts" +); +const { updateCompressionSettings } = await import("../../src/lib/db/compression.ts"); +const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts"); + +test.after(() => { + core.resetDbInstance(); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test.beforeEach(() => { + clearModelsDevCapabilities(); +}); + +function capabilityEntry(limitContext: number | null) { + 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, + }; +} + +function target(modelStr: string) { + return { + kind: "model" as const, + stepId: modelStr, + executionKey: modelStr, + modelStr, + provider: modelStr.includes("/") ? modelStr.split("/")[0] : modelStr, + providerId: null, + connectionId: null, + weight: 1, + label: null, + }; +} + +// A generic Responses-API body whose estimate lands near `tokens` tokens (4 chars/token). +// Uses `input:` (not `messages:`) to mirror the OpenCode/Codex Responses surface. +function bigResponsesBody(tokens: number) { + return { input: [["user", "x".repeat(tokens * 4)]] }; +} + +const noopLog = { info() {}, warn() {}, error() {}, debug() {} }; + +test("#10225 getKnownContextOverflow defers the hard overflow when compression is available", () => { + saveModelsDevCapabilities({ codex: { "gpt-5.6-terra": capabilityEntry(272_000) } }); + const body = bigResponsesBody(275_000); + + // Compression enabled + target can compress -> defer (null). + assert.equal( + getKnownContextOverflow([target("codex/gpt-5.6-terra")], body, { + deferContextOverflowWhenCompressible: true, + }), + null, + "compressible request must defer so chatCore compression can run (#10225)" + ); + + // Compression disabled -> the existing hard overflow is preserved (never lose #7177). + const hard = getKnownContextOverflow([target("codex/gpt-5.6-terra")], body); + assert.ok(hard); + assert.ok(hard.requiredContextTokens > hard.maxKnownContextTokens); + + // Compression enabled but EVERY target is excluded from compression -> keep the hard gate. + const excluded = getKnownContextOverflow([target("codex/gpt-5.6-terra")], body, { + deferContextOverflowWhenCompressible: true, + compressionExclusions: ["gpt-5.6-terra"], + }); + assert.ok(excluded, "fully-excluded targets must retain the hard preflight"); +}); + +test("#10225 combo does not early-400 a compressible over-limit request when deferral is on", async () => { + saveModelsDevCapabilities({ codex: { "gpt-5.6-terra": capabilityEntry(272_000) } }); + let dispatches = 0; + + const response = await handleComboChat({ + body: bigResponsesBody(275_000), + combo: { + name: "codex-compress-overflow", + strategy: "priority", + models: ["codex/gpt-5.6-terra"], + }, + deferContextOverflowWhenCompressible: true, + clientManagedResponsesContext: false, + isModelAvailable: async () => true, + handleSingleModel: async () => { + dispatches += 1; + return new Response("ok", { status: 200 }); + }, + log: noopLog, + }); + + assert.notEqual(response.status, 400, "compression-enabled request must reach chatCore"); + assert.equal(dispatches, 1, "must dispatch so chatCore compaction runs first"); +}); + +test("#10225 combo keeps the fast 400 when compression is disabled", async () => { + saveModelsDevCapabilities({ codex: { "gpt-5.6-terra": capabilityEntry(272_000) } }); + let dispatches = 0; + + const response = await handleComboChat({ + body: bigResponsesBody(275_000), + combo: { + name: "codex-compress-disabled", + strategy: "priority", + models: ["codex/gpt-5.6-terra"], + }, + deferContextOverflowWhenCompressible: false, + clientManagedResponsesContext: false, + isModelAvailable: async () => true, + handleSingleModel: async () => { + dispatches += 1; + return new Response("ok", { status: 200 }); + }, + log: noopLog, + }); + + assert.equal(response.status, 400); + assert.equal(dispatches, 0, "#7177 anti-exhaustion guard must survive when compression is off"); + const body = await response.json(); + assert.equal(body.error.code, "context_length_exceeded"); +}); + +// #10501-sweep #10503 — the deferral above is NOT target-aware by default: it only +// checks operator-named compression exclusions, never whether chatCore will actually +// attempt compression for the resolved target. handleChatCore.ts unconditionally sets +// `compressionExcluded = nativeCodexPassthrough || ...` for a verified native Codex +// Responses passthrough target (open-sse/handlers/chatCore.ts) — deferring the +// preflight there means an oversized request sails past BOTH gates uncompressed. These +// tests pin the fix: a native-codex-passthrough target must never count toward "can +// compress", so the hard preflight stays active and no upstream dispatch happens. +// NOTE on `clientManagedResponsesContext: false` below: these tests deliberately do +// NOT set it, to isolate the fix from the PRE-EXISTING, unrelated early-return a few +// lines above in knownContextOverflow.ts ("Native Codex Responses clients compact +// their own item history") — that block ALSO returns null for an all-codex pool, but +// only when `clientManagedResponsesContext === true` (a VERIFIED native client). The +// bug this fix targets is broader: chatCore's `shouldUseNativeCodexPassthrough` short- +// circuits to true for `provider === "codex"` regardless of verification (see +// passthroughHelpers.ts), so an UNVERIFIED request that nonetheless targets a `codex` +// combo member over `/v1/responses` in openai-responses format still hits chatCore's +// compression bypass — exactly the gap `sourceFormat`/`endpointPath` (not the looser +// `clientManagedResponsesContext` flag) now closes. +test("#10503 getKnownContextOverflow REFUSES to defer when the only target is native Codex Responses passthrough", () => { + saveModelsDevCapabilities({ codex: { "gpt-5.6-terra": capabilityEntry(272_000) } }); + const body = bigResponsesBody(275_000); + + const overflow = getKnownContextOverflow([target("codex/gpt-5.6-terra")], body, { + deferContextOverflowWhenCompressible: true, + sourceFormat: "openai-responses", + endpointPath: "/v1/responses", + }); + + assert.ok( + overflow, + "a native-codex-passthrough target must never be treated as compressible — the " + + "hard preflight must stay active (chatCore disables compression for it entirely)" + ); +}); + +test("#10503 getKnownContextOverflow still defers when a genuinely compressible sibling target is present", () => { + saveModelsDevCapabilities({ + codex: { "gpt-5.6-terra": capabilityEntry(272_000) }, + openai: { "gpt-5.6-terra": capabilityEntry(272_000) }, + }); + const body = bigResponsesBody(275_000); + + // A heterogeneous pool where at least ONE target (openai) genuinely runs + // compression must still defer — deferral is a per-request decision, and other + // targets in the pool are unaffected by the codex-specific compression bypass. + const overflow = getKnownContextOverflow( + [target("codex/gpt-5.6-terra"), target("openai/gpt-5.6-terra")], + body, + { + deferContextOverflowWhenCompressible: true, + sourceFormat: "openai-responses", + endpointPath: "/v1/responses", + } + ); + + assert.equal(overflow, null, "a genuinely compressible sibling target must still defer"); +}); + +test("#10503 handleComboChat: native-codex-passthrough pool fails FAST locally, zero upstream dispatches", async () => { + saveModelsDevCapabilities({ codex: { "gpt-5.6-terra": capabilityEntry(272_000) } }); + let dispatches = 0; + + const response = await handleComboChat({ + body: bigResponsesBody(275_000), + combo: { + name: "codex-native-passthrough-overflow", + strategy: "priority", + models: ["codex/gpt-5.6-terra"], + }, + deferContextOverflowWhenCompressible: true, + sourceFormat: "openai-responses", + endpointPath: "/v1/responses", + isModelAvailable: async () => true, + handleSingleModel: async () => { + dispatches += 1; + return new Response("ok", { status: 200 }); + }, + log: noopLog, + }); + + assert.equal( + response.status, + 400, + "must fail fast locally instead of dispatching an oversized, uncompressible request" + ); + assert.equal(dispatches, 0, "no wasted upstream call for a target that can never compress"); + const responseBody = await response.json(); + assert.equal(responseBody.error.code, "context_length_exceeded"); +}); + +// #10503 item 2 — drive the REAL chatCore compression pipeline end-to-end (not just the +// pure getKnownContextOverflow helper): a genuinely compressible multi-turn request must +// have chatCore's proactive/last-resort compression actually run and dispatch the +// COMPRESSED body upstream; a request that is STILL too large after compression must be +// rejected locally with zero upstream dispatch (fail-fast, matching the codex-passthrough +// case above in outcome, but via the "compression tried and wasn't enough" path instead +// of "compression was never eligible"). +// +// Uses an unregistered synthetic provider + CONTEXT_LENGTH_ env override +// (same technique as tests/unit/chatcore-combo-context-limit-8378.test.ts) so the +// context limit is small and deterministic without depending on any real catalog entry. +// Compression targets conversation HISTORY (older turns), not the current terminal +// message — this is why the fixtures below build many small history turns plus one +// short final turn (compressible case) vs one large, irreducible final turn +// (still-too-large case). +const CHATCORE_PROBE_PROVIDER = "combo10503probe"; +const CHATCORE_PROBE_MODEL = "combo10503probemodel"; +const CHATCORE_LIMIT_ENV = "CONTEXT_LENGTH_COMBO10503PROBE"; + +function buildHistoryBody(turns: number, finalMessageChars: number) { + const messages: Array<{ role: string; content: string }> = [ + { role: "system", content: "You are a helpful assistant." }, + ]; + for (let i = 0; i < turns; i++) { + messages.push({ + role: "user", + content: `Message number ${i}: Alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima mno pqr stu.`, + }); + messages.push({ + role: "assistant", + content: `Reply number ${i}: verbose filler answer with extra padding text for realism.`, + }); + } + messages.push({ role: "user", content: "FINAL: " + "z".repeat(finalMessageChars) }); + return { model: CHATCORE_PROBE_MODEL, messages, stream: false }; +} + +async function invokeChatCoreCapturingUpstream(body: Record) { + const originalFetch = globalThis.fetch; + let dispatched = false; + let sentBodyJson: string | null = null; + globalThis.fetch = async (_url: RequestInfo | URL, init: RequestInit = {}) => { + dispatched = true; + sentBodyJson = init.body ? String(init.body) : null; + return new Response( + JSON.stringify({ + id: "chatcmpl-10503", + object: "chat.completion", + model: CHATCORE_PROBE_MODEL, + choices: [ + { index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + }; + try { + const result = await handleChatCore({ + body, + modelInfo: { + provider: CHATCORE_PROBE_PROVIDER, + model: CHATCORE_PROBE_MODEL, + extendedContext: false, + }, + credentials: { apiKey: "sk-test", providerSpecificData: {} }, + log: { debug() {}, info() {}, warn() {}, error() {} }, + clientRawRequest: { + endpoint: "/v1/chat/completions", + body, + headers: new Headers({ accept: "application/json" }), + }, + userAgent: "unit-test", + } as never); + return { result, dispatched, sentBodyJson }; + } finally { + globalThis.fetch = originalFetch; + } +} + +test("#10503 real chatCore path: a compressible request dispatches the COMPRESSED (not raw) body upstream", async () => { + const originalEnv = process.env[CHATCORE_LIMIT_ENV]; + process.env[CHATCORE_LIMIT_ENV] = "500"; + await updateCompressionSettings({ + enabled: true, + defaultMode: "standard", + autoTriggerTokens: 1, + autoTriggerMode: "standard", + engines: { rtk: { enabled: true }, caveman: { enabled: true } }, + } as never); + try { + const body = buildHistoryBody(150, 50); + const rawLen = JSON.stringify(body.messages).length; + + const { dispatched, sentBodyJson } = await invokeChatCoreCapturingUpstream(body); + + assert.ok(dispatched, "compression must let a genuinely compressible request reach chatCore's dispatch"); + assert.ok(sentBodyJson, "the dispatched request must carry a body"); + assert.ok( + sentBodyJson!.length < rawLen * 0.5, + `expected the DISPATCHED body (${sentBodyJson!.length} chars) to be substantially ` + + `smaller than the raw request (${rawLen} chars) — proves compression actually ran ` + + `and its output (not the raw body) is what reached upstream` + ); + } finally { + if (originalEnv === undefined) delete process.env[CHATCORE_LIMIT_ENV]; + else process.env[CHATCORE_LIMIT_ENV] = originalEnv; + } +}); + +test("#10503 real chatCore path: STILL too large after compression → local rejection, ZERO upstream dispatch", async () => { + const originalEnv = process.env[CHATCORE_LIMIT_ENV]; + process.env[CHATCORE_LIMIT_ENV] = "50"; + await updateCompressionSettings({ + enabled: true, + defaultMode: "standard", + autoTriggerTokens: 1, + autoTriggerMode: "standard", + engines: { rtk: { enabled: true }, caveman: { enabled: true } }, + } as never); + try { + // The final turn alone (1000 chars, irreducible — compression trims HISTORY, not + // the current terminal message) already exceeds the 50-token limit, so no amount + // of history compaction can make this fit. + const body = buildHistoryBody(150, 1000); + + const { result, dispatched } = await invokeChatCoreCapturingUpstream(body); + + assert.equal( + dispatched, + false, + "fail-fast: a request that cannot fit even after compression must never reach fetch()" + ); + assert.equal((result as { success: boolean }).success, false); + const failure = result as { success: false; error?: string; rawMessage?: string }; + const message = failure.rawMessage ?? failure.error ?? ""; + assert.match(message, /exceeds/i); + } finally { + if (originalEnv === undefined) delete process.env[CHATCORE_LIMIT_ENV]; + else process.env[CHATCORE_LIMIT_ENV] = originalEnv; + } +}); diff --git a/tests/unit/combo-context-prefix-resolution.test.ts b/tests/unit/combo-context-prefix-resolution.test.ts index 05b4612347..bd586f2efc 100644 --- a/tests/unit/combo-context-prefix-resolution.test.ts +++ b/tests/unit/combo-context-prefix-resolution.test.ts @@ -33,6 +33,8 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const { computeComboContextLength } = await import("../../src/lib/combos/comboContext.ts"); +const { setModelContextOverride, removeModelContextOverride } = + await import("../../src/lib/db/modelContextOverrides.ts"); test.after(() => { core.resetDbInstance(); @@ -72,3 +74,17 @@ test("computeComboContextLength takes the minimum across multiple prefixed, regi "matching the catalog's minKnownNumber semantics" ); }); + +test("computeComboContextLength honors a larger persisted Codex GPT-5.6 window", () => { + const modelId = "gpt-5.6-terra"; + assert.equal(setModelContextOverride("codex", modelId, 500000, "manual"), true); + try { + assert.equal( + computeComboContextLength({ models: [`codex/${modelId}`] }, []), + 500000, + "the combo aggregate must use the effective override instead of the Codex registry default" + ); + } finally { + removeModelContextOverride("codex", modelId); + } +}); diff --git a/tests/unit/combo-error-aggregation.test.ts b/tests/unit/combo-error-aggregation.test.ts new file mode 100644 index 0000000000..c7f10fe8e9 --- /dev/null +++ b/tests/unit/combo-error-aggregation.test.ts @@ -0,0 +1,165 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + classifyComboOutcome, + formatComboOutcomes, + redactConnectionLabel, + buildRedactedSummary, + resolveComboTerminalStatus, +} from "../../open-sse/services/combo/comboErrorAggregation.ts"; + +// #10314 — combo error aggregation mixes quality and auth. +// Regression guard for the pure aggregation helpers: a quality-failure reason from one +// target and a sibling's 401 must be presented as SEPARATE classified outcomes (never +// mashed into a single lastError), and account/connection identifiers must be redacted +// from client-visible and shared-warn strings. + +test("#10314: classifyComboOutcome keeps auth distinct from quality/model", () => { + assert.equal(classifyComboOutcome(401, "invalid_api_key"), "auth"); + assert.equal(classifyComboOutcome(403, "not authorized"), "auth"); + assert.equal(classifyComboOutcome(408, "timeout"), "timeout"); + assert.equal(classifyComboOutcome(400, "bad request"), "model"); +}); + +// #10501: the classifier's ordering used to have `status === 408 || status >= 499` +// checked BEFORE `status >= 500` — since 500 >= 499, that made the `provider` +// branch unreachable for every real 5xx status (500/502/503/504), silently +// mislabeling every provider outage as a client-side "timeout". These cases +// pin the corrected, intentional mapping for the exact statuses call out in +// the fix: 499 (client-abort convention), 408 (request timeout), 429 (rate +// limit/quota — its own class, not lumped into `model`), and the 5xx family. +test("#10501: classifyComboOutcome — 499/408 are timeout, 429 is rate_limit, 5xx is provider (not timeout)", () => { + assert.equal(classifyComboOutcome(499, "client closed request"), "timeout"); + assert.equal(classifyComboOutcome(408, "request timeout"), "timeout"); + assert.equal(classifyComboOutcome(429, "rate limited"), "rate_limit"); + assert.equal(classifyComboOutcome(500, "internal server error"), "provider"); + assert.equal(classifyComboOutcome(502, "bad gateway"), "provider"); + assert.equal(classifyComboOutcome(503, "upstream unavailable"), "provider"); + assert.equal(classifyComboOutcome(504, "gateway timeout"), "provider"); +}); + +test("#10314: formatComboOutcomes lists quality and auth reasons SEPARATELY (both visible)", () => { + const msg = formatComboOutcomes([ + { model: "openai/model-quality", status: 502, error: "response failed quality validation", kind: "quality" }, + { model: "openai/proxy-account-b", status: 401, error: "invalid_api_key", kind: "auth" }, + ]); + assert.match(msg, /quality validation/); + assert.match(msg, /invalid_api_key/); + assert.match(msg, /auth/); + assert.ok(msg.indexOf("quality validation") < msg.indexOf("invalid_api_key")); +}); + +test("#10314: redactConnectionLabel masks connection/account identifiers", () => { + assert.equal( + redactConnectionLabel("openai/proxy-account-b"), + "openai/proxy-account-b" + ); + const withUuid = redactConnectionLabel("openai/8a4f0c6e-3b27-4c51-9d88-1f2a3b4c5d6e"); + assert.equal(withUuid, "openai/conn:8a4f0c6e"); + const withHex = redactConnectionLabel("openai/0f1e2d3c4b5a69788796170a1b2c3d4e5f607182"); + assert.equal(withHex, "openai/conn:0f1e2d3c"); +}); + +test("#10314: buildRedactedSummary is redacted and truncates past 5 entries", () => { + const s = buildRedactedSummary( + Array.from({ length: 6 }, (_, i) => ({ model: `openai/8a4f0c6e-3b27-4c51-9d88-1f2a3b4c5d6e-${i}`, status: 401 + i })) + ); + assert.ok(!s.includes("8a4f0c6e-3b27"), "summary must not leak a full UUID"); + assert.match(s, /conn:8a4f0c6e/); + assert.match(s, /\(\+1\)/); +}); + +// #10501: identifiers can ride inside the raw upstream ERROR TEXT too (some +// openai-compatible proxies echo the connection/account id back in the error +// body), not just the model label. formatComboOutcomes must redact BOTH. +test("#10501: formatComboOutcomes redacts a UUID embedded in the error TEXT, not just the model label", () => { + const msg = formatComboOutcomes([ + { + model: "openai/proxy-account-b", + status: 401, + error: "invalid key for connection 8a4f0c6e-3b27-4c51-9d88-1f2a3b4c5d6e", + kind: "auth", + }, + ]); + assert.ok(!msg.includes("8a4f0c6e-3b27-4c51-9d88-1f2a3b4c5d6e"), "must not leak the full UUID"); + assert.match(msg, /conn:8a4f0c6e/, "must redact the UUID inside the error reason text"); +}); + +test("#10501: formatComboOutcomes({redact:false}) intentionally leaves identifiers intact (internal/debug callers only)", () => { + const msg = formatComboOutcomes( + [ + { + model: "openai/proxy-account-b", + status: 401, + error: "invalid key for connection 8a4f0c6e-3b27-4c51-9d88-1f2a3b4c5d6e", + kind: "auth", + }, + ], + { redact: false } + ); + assert.ok(msg.includes("8a4f0c6e-3b27-4c51-9d88-1f2a3b4c5d6e")); +}); + +// #10501: explicit terminal-status policy for heterogeneous combo target +// exhaustion — see comboErrorAggregation.ts::resolveComboTerminalStatus header. +test("#10501: resolveComboTerminalStatus preserves 4xx only when EVERY target is a genuine request-invalid (model) failure", () => { + assert.equal( + resolveComboTerminalStatus( + [ + { model: "a", status: 400, error: "bad request", kind: "model" }, + { model: "b", status: 422, error: "unprocessable", kind: "model" }, + ], + 500 + ), + 422, + "all-model-class 4xx across every target must be preserved (the request really is invalid)" + ); +}); + +test("#10501: resolveComboTerminalStatus preserves a homogeneous non-model status (every target failed the SAME way)", () => { + assert.equal( + resolveComboTerminalStatus( + [ + { model: "a", status: 401, error: "invalid_api_key", kind: "auth" }, + { model: "b", status: 401, error: "invalid_api_key", kind: "auth" }, + ], + 500 + ), + 401, + "every target failing with the identical auth reason is still a well-defined single verdict" + ); +}); + +test("#10501: resolveComboTerminalStatus normalizes a heterogeneous mix (quality + auth) to 5xx, never a bare lastStatus 401", () => { + const status = resolveComboTerminalStatus( + [ + { + model: "a", + status: 502, + error: "response failed quality validation", + kind: "quality", + }, + { model: "b", status: 401, error: "invalid_api_key", kind: "auth" }, + ], + 401 // lastStatus — the OLD behavior would have surfaced this bare 401 + ); + assert.ok( + status >= 500, + `heterogeneous quality+auth exhaustion must surface an infra/provider 5xx, got ${status}` + ); +}); + +test("#10501: resolveComboTerminalStatus maps a heterogeneous mix containing a timeout to 504", () => { + const status = resolveComboTerminalStatus( + [ + { model: "a", status: 408, error: "request timeout", kind: "timeout" }, + { model: "b", status: 400, error: "bad request", kind: "model" }, + ], + 400 + ); + assert.equal(status, 504); +}); + +test("#10501: resolveComboTerminalStatus falls back to the caller's status when there are no structured entries", () => { + assert.equal(resolveComboTerminalStatus([], 503), 503); +}); \ No newline at end of file diff --git a/tests/unit/combo-fingerprint-expansion.test.ts b/tests/unit/combo-fingerprint-expansion.test.ts index dda557f948..4f258fe03c 100644 --- a/tests/unit/combo-fingerprint-expansion.test.ts +++ b/tests/unit/combo-fingerprint-expansion.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -// #5521 — A mimocode connection with multiple fingerprints in +// #5521 — An opencode connection with multiple fingerprints in // provider_specific_data.fingerprints was treated as a single combo target, // so only one fingerprint (one IP) was used per request. The combo system // must now expand each fingerprint into its own target so all of them @@ -17,13 +17,6 @@ const { // ── isFingerprintProvider ──────────────────────────────────────────────────── -test("isFingerprintProvider: mimocode returns true", () => { - assert.equal(isFingerprintProvider("mimocode"), true); -}); - -test("isFingerprintProvider: mcode returns true", () => { - assert.equal(isFingerprintProvider("mcode"), true); -}); test("isFingerprintProvider: opencode returns true", () => { assert.equal(isFingerprintProvider("opencode"), true); @@ -136,8 +129,8 @@ function makeTarget(overrides: Record = {}) { kind: "model" as const, stepId: "step-0", executionKey: "step-0", - modelStr: "mimocode/mimo-auto", - provider: "mimocode", + modelStr: "opencode/kimi-k2", + provider: "opencode", providerId: null, connectionId: "conn-1", weight: 0, @@ -149,7 +142,7 @@ function makeTarget(overrides: Record = {}) { function makeConnection(fps: string[]) { return { id: "conn-1", - provider: "mimocode", + provider: "opencode", providerSpecificData: { fingerprints: fps }, }; } @@ -196,7 +189,7 @@ test("expandTargetsByFingerprints: preserves all target properties across copies const fps = ["fp-aaa", "fp-bbb", "fp-ccc"]; const conn = makeConnection(fps); const targets = [ - makeTarget({ connectionId: "conn-1", modelStr: "mimocode/mimo-auto", weight: 5 }), + makeTarget({ connectionId: "conn-1", modelStr: "opencode/kimi-k2", weight: 5 }), ]; const connById = new Map([["conn-1", conn]]); const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); @@ -204,8 +197,8 @@ test("expandTargetsByFingerprints: preserves all target properties across copies for (const r of result) { assert.equal(r.kind, "model"); assert.equal(r.connectionId, "conn-1"); - assert.equal(r.modelStr, "mimocode/mimo-auto"); - assert.equal(r.provider, "mimocode"); + assert.equal(r.modelStr, "opencode/kimi-k2"); + assert.equal(r.provider, "opencode"); assert.equal(r.weight, 5); } }); @@ -242,23 +235,6 @@ test("expandTargetsByFingerprints: empty input returns empty array", () => { assert.equal(result.length, 0); }); -test("expandTargetsByFingerprints: mcode provider expands correctly", () => { - const fps = ["mfp-1", "mfp-2", "mfp-3"]; - const conn = { - id: "conn-m", - provider: "mcode", - providerSpecificData: { fingerprints: fps }, - }; - const targets = [ - makeTarget({ provider: "mcode", modelStr: "mcode/auto", connectionId: "conn-m" }), - ]; - const connById = new Map([["conn-m", conn]]); - const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); - assert.equal(result.length, 3); - assert.equal(result[0].executionKey, "step-0"); - assert.equal(result[1].executionKey, "step-0@fp:mfp-2"); - assert.equal(result[2].executionKey, "step-0@fp:mfp-3"); -}); test("expandTargetsByFingerprints: multiple targets each expand independently", () => { const conn1 = makeConnection(["fp-a1", "fp-a2"]); diff --git a/tests/unit/combo-fingerprint-pin-6696.test.ts b/tests/unit/combo-fingerprint-pin-6696.test.ts index 6a683f49e8..26d0c056eb 100644 --- a/tests/unit/combo-fingerprint-pin-6696.test.ts +++ b/tests/unit/combo-fingerprint-pin-6696.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; // #6696 — the combo builder's "pin a specific account" feature for fingerprint -// providers (mimocode/mcode/opencode) builds a composite connectionId of the +// providers (opencode) builds a composite connectionId of the // form `${rowId}|fp|${fingerprint}` (src/lib/combos/builderOptions.ts:251), but // nothing in the combo execution path ever splits that composite id back into // a real rowId + a selected fingerprint. This test proves the pin is inert: @@ -27,8 +27,8 @@ function makeTarget(overrides: Record = {}) { kind: "model" as const, stepId: "step-0", executionKey: "step-0", - modelStr: "mimocode/mimo-auto", - provider: "mimocode", + modelStr: "opencode/kimi-k2", + provider: "opencode", providerId: null, connectionId: "conn-1", weight: 0, @@ -41,7 +41,7 @@ test("#6696: fp-pinned composite connectionId is never resolved to the real conn const realConnectionId = "conn-1"; const conn = { id: realConnectionId, - provider: "mimocode", + provider: "opencode", providerSpecificData: { fingerprints: ["fp-aaa", "fp-bbb"] }, }; const connById = new Map([[realConnectionId, conn]]); @@ -81,7 +81,7 @@ test("#6696: composite connectionId never matches connectionById (root cause of const realConnectionId = "conn-1"; const conn = { id: realConnectionId, - provider: "mimocode", + provider: "opencode", providerSpecificData: { fingerprints: ["fp-aaa", "fp-bbb"] }, }; const connById = new Map([[realConnectionId, conn]]); diff --git a/tests/unit/combo-lane-awareness-9654.test.ts b/tests/unit/combo-lane-awareness-9654.test.ts new file mode 100644 index 0000000000..114791ed12 --- /dev/null +++ b/tests/unit/combo-lane-awareness-9654.test.ts @@ -0,0 +1,353 @@ +/** + * #9654 Wave 2 — combo/fusion per-target lane-aware admission. + * + * Combo and fusion fan-out dispatch N targets without ever consulting the + * adaptive-admission layer: the parent request holds one lease, but each + * fan-out target is dispatched unconditionally. With virtual lanes enabled + * (OMNIROUTE_CHAT_VIRTUAL_LANES=1), a tenant whose lane queue is full + * should SKIP additional fan-out targets instead of piling more queued work + * onto an already-congested lane. + * + * The per-target probe (`PerTargetAdmissionHook`, built by + * `createPerTargetAdmissionHook`) is: + * - strictly non-blocking (maxWaitMs 0 — skip, never queue) + * - a no-op when virtual lanes are off (the shared queue is the only gate, + * and the parent request already holds one lease — probing there would + * double-count and reject combo targets) + * - routed to the PARENT's tenantKey so it gates the same per-tenant lane + * - release-immediately on admit: the probe is a capacity gate, not a hold + * (the parent's lease covers the fan-out; holding N more would inflate + * shared active cost and reject other sessions) + * + * Run: node --import tsx/esm --test tests/unit/combo-lane-awareness-9654.test.ts + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { AdaptiveAdmissionRuntime } from "../../open-sse/services/admission/runtime.ts"; +import type { PerTargetAdmissionHook } from "../../open-sse/services/admission/types.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-lane-awareness-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const { createPerTargetAdmissionHook } = await import("../../src/sse/handlers/chatAdmission.ts"); + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +type Body = Record; + +function okResponse(content: string): Response { + return new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content } }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function priorityCombo(models: string[]) { + return { + name: "test-lane-combo", + strategy: "priority", + models: models.map((m) => ({ model: m })), + }; +} + +function rrCombo(models: string[]) { + return { + name: "test-lane-rr-combo", + strategy: "round-robin", + models: models.map((m) => ({ model: m })), + }; +} + +function fusionCombo(models: string[], extra: Record = {}) { + return { + name: "test-lane-fusion", + strategy: "fusion", + models: models.map((m) => ({ model: m })), + config: extra, + }; +} + +// ── Factory unit tests: hook semantics in isolation ──────────────────────── + +function fakeRuntime(opts: { + virtualLanes: boolean; + acquireResult?: { + status: "admitted" | "rejected"; + lease?: { released: boolean; release: (outcome?: string) => void }; + }; + acquireError?: Error; +}): AdaptiveAdmissionRuntime & { acquireCalls: Array> } { + const acquireCalls: Array> = []; + const runtime = { + acquireCalls, + async acquire(input: Record) { + acquireCalls.push(input); + if (opts.acquireError) throw opts.acquireError; + if (opts.acquireResult) return opts.acquireResult; + throw new Error("fake runtime: acquireResult not configured"); + }, + snapshot() { + return { virtualLanes: opts.virtualLanes }; + }, + } as unknown as AdaptiveAdmissionRuntime & { acquireCalls: Array> }; + return runtime; +} + +test("probe is a no-op when virtual lanes are off (never calls acquire)", async () => { + const runtime = fakeRuntime({ virtualLanes: false }); + const hook = createPerTargetAdmissionHook(runtime, "tenant-a"); + const ok = await hook({ modelStr: "p/model", executionKey: "k1", body: {} }); + assert.equal(ok, true, "lanes off must pass every target through"); + assert.equal(runtime.acquireCalls.length, 0, "no acquire when lanes are off"); +}); + +test("probe is strictly non-blocking: maxWaitMs 0, parent tenantKey, release-on-admit", async () => { + let released = false; + const runtime = fakeRuntime({ + virtualLanes: true, + acquireResult: { + status: "admitted", + lease: { + released: false, + release: () => { + released = true; + }, + }, + }, + }); + const hook = createPerTargetAdmissionHook(runtime, "tenant-parent", null); + const ok = await hook({ modelStr: "p/model", executionKey: "k1", body: { messages: [] } }); + + assert.equal(ok, true, "admitted probe must proceed"); + assert.equal(runtime.acquireCalls.length, 1); + assert.equal(runtime.acquireCalls[0].tenantKey, "tenant-parent", "must gate the parent's lane"); + assert.equal(runtime.acquireCalls[0].maxWaitMs, 0, "strictly non-blocking: never queue"); + assert.deepEqual( + runtime.acquireCalls[0].body, + { messages: [] }, + "probe must estimate cost from the real target body" + ); + assert.equal( + runtime.acquireCalls[0].streaming, + false, + "absent stream flag must price the target like the parent path (non-streaming class)" + ); + assert.equal( + released, + true, + "probe lease must be released immediately (capacity gate, not a hold)" + ); +}); + +test("probe prices the request class the target will actually dispatch", async () => { + const runtime = fakeRuntime({ + virtualLanes: true, + acquireResult: { status: "admitted", lease: { released: false, release: () => {} } }, + }); + const hook = createPerTargetAdmissionHook(runtime, "tenant-parent"); + + await hook({ modelStr: "p/model", executionKey: "k1", body: { stream: true } }); + assert.equal( + runtime.acquireCalls[0].streaming, + true, + "stream:true fan-out must be priced at streaming class (1)" + ); + + await hook({ modelStr: "p/model", executionKey: "k2", body: { stream: false } }); + assert.equal( + runtime.acquireCalls[1].streaming, + false, + "stream:false fan-out (e.g. fusion panel) must be priced at non-streaming class (2)" + ); + + await hook({ modelStr: "p/model", executionKey: "k3", body: { messages: [] } }); + assert.equal( + runtime.acquireCalls[2].streaming, + false, + "absent stream flag must match the parent path (non-streaming class)" + ); +}); + +test("probe skips the target when its lane is full (rejected acquire)", async () => { + const runtime = fakeRuntime({ + virtualLanes: true, + acquireResult: { + status: "rejected", + lease: undefined, + }, + }); + const hook = createPerTargetAdmissionHook(runtime, "tenant-parent"); + const ok = await hook({ modelStr: "p/model", executionKey: "k1", body: {} }); + assert.equal(ok, false, "lane-full probe must skip the target"); +}); + +test("probe fails open when the admission layer throws (never fails the fan-out)", async () => { + const runtime = fakeRuntime({ + virtualLanes: true, + acquireError: new Error("admission layer hiccup"), + }); + const hook = createPerTargetAdmissionHook(runtime, "tenant-parent"); + const ok = await hook({ modelStr: "p/model", executionKey: "k1", body: {} }); + assert.equal(ok, true, "an admission-layer error must let the target dispatch ungated"); +}); + +test("probe forwards the abort signal", async () => { + const ac = new AbortController(); + ac.abort(); + const runtime = fakeRuntime({ + virtualLanes: true, + acquireResult: { status: "rejected", lease: undefined }, + }); + const hook = createPerTargetAdmissionHook(runtime, "tenant-parent", ac.signal); + await hook({ modelStr: "p/model", executionKey: "k1", body: {} }); + assert.equal(runtime.acquireCalls[0].signal, ac.signal, "probe must forward the parent signal"); +}); + +// ── Integration: combo priority skips lane-full targets ──────────────────── + +test("priority combo: lane-full target is skipped before dispatch, healthy target serves", async () => { + const calls: string[] = []; + const handleSingleModel = async (_b: Body, m: string) => { + calls.push(m); + return okResponse(`ans-${m}`); + }; + // Lane full for the FIRST target only → it must be skipped, second serves. + const perTargetAdmission: PerTargetAdmissionHook = async (t) => t.modelStr !== "p/first"; + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: priorityCombo(["p/first", "p/second"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + perTargetAdmission, + }); + + assert.deepEqual(calls, ["p/second"], "lane-full first target must be skipped"); + assert.equal(res.status, 200); +}); + +test("priority combo: all targets lane-full falls through to the exhausted path", async () => { + const calls: string[] = []; + const handleSingleModel = async (_b: Body, m: string) => { + calls.push(m); + return okResponse(`ans-${m}`); + }; + const perTargetAdmission: PerTargetAdmissionHook = async () => false; // every target skipped + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: priorityCombo(["p/first", "p/second"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + perTargetAdmission, + }); + + assert.equal(calls.length, 0, "no target may be dispatched when every lane is full"); + assert.ok( + [503, 502].includes(res.status), + `expected a service-unavailable status, got ${res.status}` + ); +}); + +// ── Integration: round-robin skips lane-full targets ─────────────────────── + +test("round-robin: lane-full target is skipped, next target serves", async () => { + const calls: string[] = []; + const handleSingleModel = async (_b: Body, m: string) => { + calls.push(m); + return okResponse(`ans-${m}`); + }; + const perTargetAdmission: PerTargetAdmissionHook = async (t) => t.modelStr !== "p/first"; + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: rrCombo(["p/first", "p/second"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + perTargetAdmission, + }); + + assert.deepEqual(calls, ["p/second"], "round-robin must skip the lane-full first target"); + assert.equal(res.status, 200); +}); + +// ── Integration: fusion drops lane-full panel members before fan-out ─────── + +test("fusion: lane-full panel member is dropped before fan-out, judge still runs", async () => { + const calls: string[] = []; + const handleSingleModel = async (_b: Body, m: string) => { + calls.push(m); + if (m === "p/judge") return okResponse("FINAL"); + return okResponse(`ans-${m}`); + }; + const perTargetAdmission = async (t: { modelStr: string }) => t.modelStr !== "p/dropped"; + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: fusionCombo(["p/keep", "p/dropped"], { judgeModel: "p/judge" }), + handleSingleModel, + log, + settings: {}, + allCombos: [], + perTargetAdmission, + }); + + assert.ok(!calls.includes("p/dropped"), "lane-full panel member must never be dispatched"); + assert.ok(calls.includes("p/keep"), "healthy panel member must still fan out"); + assert.ok(calls.includes("p/judge"), "judge synthesis must still run"); + assert.equal(res.status, 200); +}); + +test("fusion: all panel members lane-full returns 503 before any fan-out", async () => { + const calls: string[] = []; + const handleSingleModel = async (_b: Body, m: string) => { + calls.push(m); + return okResponse(`ans-${m}`); + }; + const perTargetAdmission: PerTargetAdmissionHook = async () => false; // every member skipped + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: fusionCombo(["p/keep", "p/other"], { judgeModel: "p/judge" }), + handleSingleModel, + log, + settings: {}, + allCombos: [], + perTargetAdmission, + }); + + assert.equal(calls.length, 0, "no panel member may dispatch when every lane is full"); + assert.equal(res.status, 503, "all-skipped fusion must return 503, not synthesize with nothing"); +}); + +test("fusion: no hook passed behaves exactly as before (no lane awareness)", async () => { + const calls: string[] = []; + const handleSingleModel = async (_b: Body, m: string) => { + calls.push(m); + if (m === "p/judge") return okResponse("FINAL"); + return okResponse(`ans-${m}`); + }; + + const res = await handleComboChat({ + body: { messages: [{ role: "user", content: "hi" }] }, + combo: fusionCombo(["p/keep", "p/other"], { judgeModel: "p/judge" }), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + assert.deepEqual(calls.sort(), ["p/judge", "p/keep", "p/other"], "no hook = full panel fan-out"); + assert.equal(res.status, 200); +}); diff --git a/tests/unit/combo-quota-exhaustion-only-fallback.test.ts b/tests/unit/combo-quota-exhaustion-only-fallback.test.ts index eb4a7baaff..2a90d7e3fb 100644 --- a/tests/unit/combo-quota-exhaustion-only-fallback.test.ts +++ b/tests/unit/combo-quota-exhaustion-only-fallback.test.ts @@ -458,6 +458,15 @@ test("opted-in combo ref remains a black box and only quota exhaustion advances } }); +// #10501: the child combo's terminal status is now derived from +// resolveComboTerminalStatus instead of a bare `lastStatus`. A quality failure +// (openai/quality-invalid) mixed with a genuine quota-exhaustion 429 +// (anthropic/quota) is a heterogeneous, non-"the request itself is invalid" +// mix, so it normalizes to a 5xx — the `fallbackOnlyOnQuotaExhaustion` STOP +// decision itself is untouched (it is driven by internal quota-observation +// tracking, not by re-reading the final HTTP status — asserted below via +// `calls`, which must still show the parent stopping instead of falling back +// to paid/backup). test("protected parent combo-ref stops after normal child quality rejection then quota exhaustion", async () => { const calls: string[] = []; const child = { @@ -496,8 +505,16 @@ test("protected parent combo-ref stops after normal child quality rejection then : ok(modelStr); }, }); - assert.equal(result.status, 429); - assert.deepEqual(calls, ["openai/quality-invalid", "anthropic/quota"]); + assert.ok( + result.status >= 500, + `heterogeneous quality+quota-exhaustion mix must normalize to a 5xx, got ${result.status}` + ); + assert.deepEqual( + calls, + ["openai/quality-invalid", "anthropic/quota"], + "the parent must still STOP at the child (not fall back to paid/backup) — the " + + "fallbackOnlyOnQuotaExhaustion decision is unaffected by the status-code change" + ); }); test("protected parent combo-ref stops when a child has mixed quota and non-quota failures", async () => { diff --git a/tests/unit/combo-routing-engine.test.ts b/tests/unit/combo-routing-engine.test.ts index 5997fc83a5..bcf8f5dc0d 100644 --- a/tests/unit/combo-routing-engine.test.ts +++ b/tests/unit/combo-routing-engine.test.ts @@ -897,7 +897,15 @@ test("handleComboChat records per-target metrics separately when the same model assert.equal(metrics.byTarget[secondStep.id].connectionId, "conn-openai-b"); }); -test("handleComboChat surfaces the last failing target's status AND error message together, not a cross-target mismatch (#8486)", async () => { +// #10314/#10501: superseded the original "last writer wins" contract (a single +// `lastError` + raw `[model (status), ...]` suffix). Combo terminal aggregation +// now lists every distinct per-target reason separately (comboErrorAggregation.ts +// ::formatComboOutcomes) and derives the terminal status from an explicit policy +// instead of whichever target happened to fail LAST — a provider 500 mixed with a +// rate_limit 429 is a heterogeneous, non-client-fault outcome, so it normalizes to +// a 5xx (::resolveComboTerminalStatus), never a bare 429 that would misrepresent +// model-a's real 500 as "the client should retry the rate limit". +test("handleComboChat surfaces EVERY failing target's reason (never drops one) and normalizes a heterogeneous 500+429 mix to 5xx (#8486/#10314/#10501)", async () => { const result = await handleComboChat({ body: {}, combo: { @@ -918,11 +926,12 @@ test("handleComboChat surfaces the last failing target's status AND error messag const payload = (await result.json()) as any; - assert.equal(result.status, 429); // #8486: status/message from the SAME (last) failing target - // The last error message is preserved and now carries an aggregated - // per-model diagnostics suffix (status codes for every target attempted - // in this set try), added alongside the global comboTimeoutMs feature. - assert.equal(payload.error.message, "fail:model-b [model-a (500), model-b (429)]"); + assert.ok( + result.status >= 500, + `heterogeneous provider(500)+rate_limit(429) must normalize to a 5xx status, got ${result.status}` + ); + assert.match(payload.error.message, /model-a.*fail:model-a.*HTTP 500/); + assert.match(payload.error.message, /model-b.*fail:model-b.*HTTP 429/); }); interface ComboErrorPayload { @@ -1679,7 +1688,12 @@ test("handleComboChat round-robin falls through generic 400s when a later model assert.deepEqual(calls, ["model-a", "model-b"]); }); -test("handleComboChat round-robin falls through 400s and returns the LAST target's status+message together, not a cross-target mismatch (#8486)", async () => { +// #10314/#10501: same policy update as the priority-strategy test above, applied +// to the round-robin twin. model-a's 400 is a genuine request-shape/model-class +// error, but model-b's 500 is an infra/provider failure — since NOT every target +// failed with a "model" (request-is-invalid) reason, this is a heterogeneous mix +// and must normalize to a 5xx, never a bare "trust the last target's status" 500. +test("handleComboChat round-robin surfaces EVERY target's reason and normalizes a heterogeneous 400+500 mix to 5xx (#8486/#10314/#10501)", async () => { const calls: any[] = []; const result = await handleComboChat({ @@ -1717,8 +1731,12 @@ test("handleComboChat round-robin falls through 400s and returns the LAST target }); const payload = (await result.json()) as any; - assert.equal(result.status, 500); // #8486: status/message from the SAME (last) failing target - assert.equal(payload.error.message, "rr-final-fail"); + assert.ok( + result.status >= 500, + `heterogeneous model(400)+provider(500) mix must normalize to a 5xx status, got ${result.status}` + ); + assert.match(payload.error.message, /model-a.*unsupported message role.*HTTP 400/); + assert.match(payload.error.message, /model-b.*rr-final-fail.*HTTP 500/); assert.deepEqual(calls, ["model-a", "model-b"]); }); diff --git a/tests/unit/combo-system-prompt-templates-5501.test.ts b/tests/unit/combo-system-prompt-templates-5501.test.ts index 73c1aaa5cb..e31e14fde3 100644 --- a/tests/unit/combo-system-prompt-templates-5501.test.ts +++ b/tests/unit/combo-system-prompt-templates-5501.test.ts @@ -157,12 +157,11 @@ test("resolveTargetFingerprint: pinned fingerprint wins", () => { }); test("resolveTargetFingerprint: parses @fp: suffix from executionKey", () => { - assert.equal(resolveTargetFingerprint({ provider: "mcode", executionKey: "k@fp:abc" }), "abc"); + assert.equal(resolveTargetFingerprint({ provider: "opencode", executionKey: "k@fp:abc" }), "abc"); }); test("resolveTargetFingerprint: null when no source", () => { assert.equal(resolveTargetFingerprint({ provider: "opencode", executionKey: "k" }), null); - assert.equal(resolveTargetFingerprint({ provider: "mimocode" }), null); }); // ── Integration: hook + gate through handleComboChat (#5501) ────────────────── diff --git a/tests/unit/combo-terminal-status-policy-10501.test.ts b/tests/unit/combo-terminal-status-policy-10501.test.ts new file mode 100644 index 0000000000..f4b8076bc5 --- /dev/null +++ b/tests/unit/combo-terminal-status-policy-10501.test.ts @@ -0,0 +1,123 @@ +/** + * #10314 / #10501 — integration-level regression for the combo terminal-error + * aggregation + status policy. Drives the REAL `handleComboChat` wiring (via + * an injected `handleSingleModel`, the same seam `combo-body-specific-400- + * stop-4279.test.ts` uses) end-to-end, asserting the actual HTTP `Response` + * the client receives — not just the pure `comboErrorAggregation.ts` helpers + * in isolation (those are covered by `combo-error-aggregation.test.ts`). + * + * Scenario: a priority combo where target #1 returns a 200 that FAILS + * response-quality validation (empty body — see validateQuality.ts) and + * target #2 returns a real 401 (auth). Before #10314/#10501 this surfaced a + * bare 401 ("last writer wins") and dropped the quality reason entirely; now + * it must list BOTH reasons and surface a 5xx-class infra status instead of + * the sibling's 401 (resolveComboTerminalStatus — the request itself was + * never proven invalid on every target). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-10501-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "combo-10501-test-secret"; + +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); + +const noop = () => {}; +const log = { info: noop, warn: noop, debug: noop, error: noop }; + +function makeCombo(models: string[]) { + return { + name: "test-combo-10501", + strategy: "priority", + models: models.map((m) => ({ model: m })), + }; +} + +// Empty body + application/json content-type trips validateResponseQuality's +// "empty response body" case for a non-streaming response. +function qualityFailingResponse() { + return new Response("", { status: 200, headers: { "Content-Type": "application/json" } }); +} + +function authFailureResponse() { + return new Response(JSON.stringify({ error: { message: "invalid_api_key" } }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); +} + +function successResponse() { + return new Response( + JSON.stringify({ + id: "chatcmpl-1", + choices: [{ index: 0, message: { role: "assistant", content: "hi there" }, finish_reason: "stop" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); +} + +test("#10314/#10501: quality failure on target 1 + auth 401 on target 2 → 5xx terminal status, BOTH reasons listed", async () => { + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + if (modelStr.includes("model-a")) return qualityFailingResponse(); + return authFailureResponse(); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }], stream: false }, + combo: makeCombo(["openai/model-a", "openai/model-b"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + assert.deepEqual(modelsCalled, ["openai/model-a", "openai/model-b"]); + + // #10501: a heterogeneous quality+auth mix must NOT surface the sibling's + // bare 401 (the old `lastStatus` behavior) — it is an infra/provider-class + // outcome (neither target proved the CLIENT's request itself was invalid). + assert.ok( + result.status >= 500, + `expected a 5xx terminal status for a heterogeneous quality+auth mix, got ${result.status}` + ); + assert.notEqual(result.status, 401, "must not regress to surfacing the sibling target's bare 401"); + + const body = (await result.json()) as { error?: { message?: string } }; + const message = body.error?.message ?? ""; + // #10314: both distinct reasons must be listed — neither silently dropped. + assert.match(message, /quality/i, "quality-validation reason must be present in the message"); + assert.match(message, /invalid_api_key|auth/i, "auth reason must be present in the message"); +}); + +test("#10314: success on target 2 after a quality failure on target 1 returns the real success response", async () => { + const modelsCalled: string[] = []; + const handleSingleModel = async (_body: unknown, modelStr: string) => { + modelsCalled.push(modelStr); + if (modelStr.includes("model-a")) return qualityFailingResponse(); + return successResponse(); + }; + + const result = await handleComboChat({ + body: { model: "test", messages: [{ role: "user", content: "hi" }], stream: false }, + combo: makeCombo(["openai/model-a", "openai/model-b"]), + handleSingleModel, + log, + settings: {}, + allCombos: [], + }); + + assert.deepEqual( + modelsCalled, + ["openai/model-a", "openai/model-b"], + "must fail over from the quality-rejected target 1 to target 2" + ); + assert.equal(result.status, 200, "combo must return the successful target's response"); + const body = (await result.json()) as { choices?: Array<{ message?: { content?: string } }> }; + assert.equal(body.choices?.[0]?.message?.content, "hi there"); +}); diff --git a/tests/unit/combo/speech-combo.test.ts b/tests/unit/combo/speech-combo.test.ts new file mode 100644 index 0000000000..d011de5ba4 --- /dev/null +++ b/tests/unit/combo/speech-combo.test.ts @@ -0,0 +1,124 @@ +/** + * Tests for speech combo strategy execution + * + * Mirrors tests/unit/combo/image-combo.test.ts. executeSpeechCombo takes no + * logger argument — the speech handler returns a Response directly rather than + * a result object, so there is nothing for the strategy to log through. + */ +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-speech-combo-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-for-speech-combo-tests"; + +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + +const core = await import("@/lib/db/core.ts"); +const { createCombo } = await import("@/lib/db/combos"); +const { executeSpeechCombo } = await import("@omniroute/open-sse/services/speechCombo"); + +function createRequest(model: string): Request { + return new Request("http://localhost:20128/v1/audio/speech", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, input: "hello there" }), + }); +} + +function createMockAuth() { + return { + request: createRequest("test-combo"), + policy: { apiKeyInfo: { id: "test-key", name: "test-key" } }, + }; +} + +async function cleanupTestDataDir() { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + return; + } catch (error: unknown) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + if (lastError) throw lastError; +} + +test.beforeEach(async () => { + await cleanupTestDataDir(); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(async () => { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + await cleanupTestDataDir(); +}); + +test("returns 400 when combo is not found", async () => { + const response = await executeSpeechCombo( + "nonexistent-combo", + { model: "nonexistent-combo", input: "hello there" }, + createMockAuth(), + Date.now() + ); + assert.equal(response.status, 400); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); + +test("returns 400 when combo has no speech-capable targets", async () => { + await createCombo({ + name: "chat-only-combo", + strategy: "priority", + models: ["openai/gpt-4o"], + }); + + const response = await executeSpeechCombo( + "chat-only-combo", + { model: "chat-only-combo", input: "hello there" }, + createMockAuth(), + Date.now() + ); + assert.equal(response.status, 400); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(bodyStr.includes("No speech-capable targets"), "Tells user no speech targets"); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); + +test("returns 400 when combo has no usable targets", async () => { + await createCombo({ name: "empty-combo", strategy: "priority", models: [] }); + + const response = await executeSpeechCombo( + "empty-combo", + { model: "empty-combo", input: "hello there" }, + createMockAuth(), + Date.now() + ); + assert.equal(response.status, 400); +}); + +test("fails cleanly when speech targets exist but no provider connection does", async () => { + await createCombo({ + name: "spc-no-conn", + strategy: "fill-first", + models: ["deepgram/aura-asteria-en"], + }); + + const response = await executeSpeechCombo( + "spc-no-conn", + { model: "spc-no-conn", input: "hello there" }, + createMockAuth(), + Date.now() + ); + assert.ok(response.status >= 400, "Surfaces a failure rather than a fake success"); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); diff --git a/tests/unit/combo/video-combo.test.ts b/tests/unit/combo/video-combo.test.ts new file mode 100644 index 0000000000..d429ada930 --- /dev/null +++ b/tests/unit/combo/video-combo.test.ts @@ -0,0 +1,149 @@ +/** + * Tests for video combo strategy execution + * + * Mirrors tests/unit/combo/image-combo.test.ts. Seeds a temp DATA_DIR with a + * combo in the DB so executeVideoCombo resolves targets through the real DB + * path, and covers combo resolution, target filtering, and error paths. + */ +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-video-combo-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.JWT_SECRET = "test-jwt-secret-for-video-combo-tests"; + +fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); + +const core = await import("@/lib/db/core.ts"); +const { createCombo } = await import("@/lib/db/combos"); +const { executeVideoCombo } = await import("@omniroute/open-sse/services/videoCombo"); + +type LogEntry = { level: string; tag: unknown; msg: unknown }; + +function createLog() { + const entries: LogEntry[] = []; + const record = + (level: string) => + (tag: unknown, msg: unknown): number => + entries.push({ level, tag, msg }); + return { + info: record("info"), + warn: record("warn"), + error: record("error"), + debug: record("debug"), + entries, + }; +} + +function createRequest(model: string): Request { + return new Request("http://localhost:20128/v1/videos/generations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model, prompt: "a red cube" }), + }); +} + +function createMockAuth() { + return { + request: createRequest("test-combo"), + policy: { apiKeyInfo: { id: "test-key", name: "test-key" } }, + }; +} + +async function cleanupTestDataDir() { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + return; + } catch (error: unknown) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + if (lastError) throw lastError; +} + +test.beforeEach(async () => { + await cleanupTestDataDir(); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +}); + +test.after(async () => { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + await cleanupTestDataDir(); +}); + +test("returns 400 when combo is not found", async () => { + const log = createLog(); + const response = await executeVideoCombo( + "nonexistent-combo", + { model: "nonexistent-combo", prompt: "a red cube" }, + createMockAuth(), + Date.now(), + log + ); + assert.equal(response.status, 400); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); + +test("returns 400 when combo has no video-capable targets", async () => { + await createCombo({ + name: "chat-only-combo", + strategy: "priority", + models: ["openai/gpt-4o"], + }); + + const log = createLog(); + const response = await executeVideoCombo( + "chat-only-combo", + { model: "chat-only-combo", prompt: "a red cube" }, + createMockAuth(), + Date.now(), + log + ); + assert.equal(response.status, 400); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(bodyStr.includes("No video-capable targets"), "Tells user no video targets"); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); + +test("returns 400 when combo has no usable targets", async () => { + await createCombo({ name: "empty-combo", strategy: "priority", models: [] }); + + const log = createLog(); + const response = await executeVideoCombo( + "empty-combo", + { model: "empty-combo", prompt: "a red cube" }, + createMockAuth(), + Date.now(), + log + ); + assert.equal(response.status, 400); +}); + +test("fails cleanly when video targets exist but no provider connection does", async () => { + await createCombo({ + name: "vid-no-conn", + strategy: "fill-first", + models: ["runwayml/gen4_turbo"], + }); + + const log = createLog(); + const response = await executeVideoCombo( + "vid-no-conn", + { model: "vid-no-conn", prompt: "a red cube" }, + createMockAuth(), + Date.now(), + log + ); + assert.ok(response.status >= 400, "Surfaces a failure rather than a fake success"); + const bodyStr = JSON.stringify(await response.json()); + assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces"); +}); diff --git a/tests/unit/compliance-index.test.ts b/tests/unit/compliance-index.test.ts index 8229148fd3..f99a5a4b26 100644 --- a/tests/unit/compliance-index.test.ts +++ b/tests/unit/compliance-index.test.ts @@ -92,6 +92,12 @@ test("compliance audit log supports structured filters, totals and secret redact nested: { refreshToken: "refresh-secret", }, + providerSpecificData: { + extraApiKeys: ["sk-extra-1", "sk-extra-2"], + token: "token-secret", + userToken: "user-token-secret", + cookie: "cookie-secret", + }, changedFields: ["defaultModel"], }, ipAddress: "10.0.0.4", @@ -133,6 +139,12 @@ test("compliance audit log supports structured filters, totals and secret redact nested: { refreshToken: "[redacted]", }, + providerSpecificData: { + extraApiKeys: "[redacted]", + token: "[redacted]", + userToken: "[redacted]", + cookie: "[redacted]", + }, changedFields: ["defaultModel"], }); assert.deepEqual(updatedEntry.metadata, updatedEntry.details); diff --git a/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts b/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts index a21a3af9ff..73f7af4875 100644 --- a/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts +++ b/tests/unit/compression/omniglyph-chatcore-plumbing.test.ts @@ -7,6 +7,6 @@ test("chatCore treats both Anthropic providers as direct OmniGlyph transports", assert.match( chatCore, - /providerTransport:\s*provider === "anthropic" \|\| provider === "claude"[\s\S]{0,80}?"direct"/ + /providerTransport:\s*provider === "anthropic"\s*\|\|\s*provider === "claude"[\s\S]{0,160}?"direct"/ ); }); diff --git a/tests/unit/compression/output-styles-i18n-matrix.test.ts b/tests/unit/compression/output-styles-i18n-matrix.test.ts index 90d0fa373f..9fc8e8b3f1 100644 --- a/tests/unit/compression/output-styles-i18n-matrix.test.ts +++ b/tests/unit/compression/output-styles-i18n-matrix.test.ts @@ -30,9 +30,6 @@ const REQUIRED_LANGUAGES = ["pt-BR"]; * Do NOT add entries here without an issue — fix the coverage instead. */ const KNOWN_ENGLISH_ONLY: Record = { - // 9router port that never got translated. Tracked in the compression i18n - // backlog; the fix is mechanical (same shape as ponytail/i-have-adhd). - "less-code": "pre-existing gap — English-only since the 9router port", }; /** @@ -41,9 +38,9 @@ const KNOWN_ENGLISH_ONLY: Record = { */ const BASELINE_LANGUAGES: Record = { // terse-prose reuses CAVEMAN_INSTRUCTION_BY_LANGUAGE (outputMode.ts), which - // localizes to pt-BR/ja/id — keep the two in sync when adding a language. - "terse-prose": ["pt-BR", "ja", "id"], - "less-code": [], + // localizes to pt-BR/ja/id/vi — keep the two in sync when adding a language. + "terse-prose": ["pt-BR", "ja", "id", "vi"], + "less-code": ["pt-BR", "vi", "ja", "id"], ponytail: ["pt-BR", "vi", "ja", "id"], "i-have-adhd": ["pt-BR", "vi", "ja", "id"], // locale-gated to zh: the single-language instruction IS the feature. diff --git a/tests/unit/compression/pipeline-circuit-breaker.test.ts b/tests/unit/compression/pipeline-circuit-breaker.test.ts index 49fec449a1..e914ad3224 100644 --- a/tests/unit/compression/pipeline-circuit-breaker.test.ts +++ b/tests/unit/compression/pipeline-circuit-breaker.test.ts @@ -100,6 +100,7 @@ describe("pipelineEngineBreaker — pipeline integration", () => { name: "throwing test engine", targets: ["messages"], stackable: true, + metadata: { executionStages: ["pre-translation"] }, apply() { calls += 1; throw new Error("boom"); diff --git a/tests/unit/conversationTracker.test.ts b/tests/unit/conversationTracker.test.ts new file mode 100644 index 0000000000..fb61360445 --- /dev/null +++ b/tests/unit/conversationTracker.test.ts @@ -0,0 +1,640 @@ +/** + * Unit tests for the agentic conversation tracker + * (open-sse/services/conversationTracker.ts). + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-conv-tracker-")); +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "conversation-tracker-test-secret"; + +// Dynamic imports (not static) are required here: a static `import` of a module +// that reads process.env.DATA_DIR at its own top level (src/lib/db/core.ts's +// `export const DATA_DIR = ...`) is evaluated before this file's own top-level +// code runs — ESM instantiates the whole dependency graph, dependencies first, +// regardless of source-line order — so the override above would silently miss +// and the module would resolve the real host DATA_DIR instead of the temp dir. +const { extractCanonicalTurns, computeFingerprintHash, resolveConversationId, hashTurnContent } = + await import("../../open-sse/services/conversationTracker.ts"); +const { getConversationTurnPage } = await import("../../src/lib/db/agenticConversations.ts"); + +let correlationCounter = 0; +function nextCorrelationId(): string { + correlationCounter += 1; + return `corr-${correlationCounter}`; +} + +// conversation_turn_nodes stores identity only (content_hash), never display +// text — see migration 156 and conversationTurnContent.ts. Tests that need +// to assert WHICH turns ended up on a chain compare content hashes instead +// of stored text. +function hashOfPlainTextTurn(role: "user" | "assistant" | "system" | "tool", text: string): string { + return hashTurnContent({ role, text, blockKind: "text", toolName: null }); +} + +test("extractCanonicalTurns: OpenAI messages array", () => { + const turns = extractCanonicalTurns({ + messages: [ + { role: "system", content: "be helpful" }, + { role: "user", content: "hi" }, + { role: "assistant", content: "hello!" }, + ], + }); + assert.deepEqual( + turns.map((t) => t.role), + ["system", "user", "assistant"] + ); + assert.equal(turns[0].text, "be helpful"); +}); + +test("extractCanonicalTurns: Responses API input array", () => { + const turns = extractCanonicalTurns({ + input: [ + { role: "user", content: [{ type: "input_text", text: "check the file" }] }, + { type: "function_call", name: "exec", call_id: "c1", arguments: '{"command":"ls"}' }, + { type: "function_call_output", call_id: "c1", output: "ok" }, + ], + }); + assert.equal(turns.length, 3); + assert.equal(turns[0].role, "user"); + // Regression: content-block arrays (Responses API's `input_text`/ + // `output_text` shape) must extract their `.text`, not JSON.stringify the + // whole block array — a raw JSON blob here directly becomes what + // /dashboard/conversations renders as a turn's text. + assert.equal(turns[0].text, "check the file"); + assert.equal(turns[1].role, "tool"); + // `arguments` here is already a JSON string (how OpenAI/Responses API send + // tool-call arguments) — stringifyContent passes strings through as-is, + // only the content-BLOCK-ARRAY case (turns[0] above) needed the fix. + assert.equal(turns[1].text, '{"command":"ls"}'); + assert.equal(turns[2].role, "tool"); + assert.equal(turns[2].text, "ok"); + + // blockKind/toolName let a consumer (the /dashboard/conversations tree) + // build the same NormalizedBlock shape the request-detail panel already + // builds, so tool calls/results render through the same ChatBubble/ + // MessageContent/ToolCallBlock/ToolResultBlock components everywhere. + assert.equal(turns[0].blockKind, "text"); + assert.equal(turns[0].toolName, null); + assert.equal(turns[1].blockKind, "tool_use"); + assert.equal(turns[1].toolName, "exec"); + assert.equal(turns[2].blockKind, "tool_result"); + assert.equal(turns[2].toolName, null); +}); + +test("extractCanonicalTurns: Chat Completions tool-result message (role: tool) classifies as tool_result", () => { + const turns = extractCanonicalTurns({ + messages: [ + { role: "user", content: "what's the weather?" }, + { role: "tool", tool_call_id: "c1", content: '{"tempC":21}' }, + ], + }); + assert.equal(turns[0].blockKind, "text"); + assert.equal(turns[1].role, "tool"); + assert.equal(turns[1].blockKind, "tool_result"); + assert.equal(turns[1].text, '{"tempC":21}'); +}); + +test("extractCanonicalTurns: content-block arrays (Anthropic/Responses-API shape) extract text, not raw JSON", () => { + const turns = extractCanonicalTurns({ + messages: [ + { role: "user", content: [{ type: "text", text: "hello there" }] }, + { role: "assistant", content: [{ type: "output_text", text: "hi back" }] }, + ], + }); + assert.equal(turns[0].text, "hello there"); + assert.equal(turns[1].text, "hi back"); + assert.ok(!turns[0].text.includes("{"), "must not contain raw JSON"); + assert.ok(!turns[1].text.includes("{"), "must not contain raw JSON"); +}); + +test("extractCanonicalTurns: Responses API bare-string input", () => { + const turns = extractCanonicalTurns({ input: "just a string" }); + assert.equal(turns.length, 1); + assert.equal(turns[0].role, "user"); + assert.equal(turns[0].text, "just a string"); +}); + +test("computeFingerprintHash: same inputs produce the same hash", () => { + const a = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: [] }); + const b = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: [] }); + assert.equal(a, b); +}); + +test("computeFingerprintHash: different apiKeyId or model changes the hash", () => { + const base = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: [] }); + const diffKey = computeFingerprintHash({ apiKeyId: "key2", model: "gpt-4o", toolNames: [] }); + const diffModel = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-5", toolNames: [] }); + assert.notEqual(base, diffKey); + assert.notEqual(base, diffModel); +}); + +test("computeFingerprintHash: identical apiKeyId/model/toolNames produce the same hash regardless of message content", () => { + // The whole point of the fix: real OpenClaw traffic rotates its earliest + // turns out of a sliding context window, so the bucket key must not + // depend on message text at all — actual identity is decided later by the + // turn-chain walk (real content overlap), not by this coarse bucket. + const a = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: ["exec"] }); + const b = computeFingerprintHash({ apiKeyId: "key1", model: "gpt-4o", toolNames: ["exec"] }); + assert.equal(a, b); +}); + +test("resolveConversationId: exact-match continuation reuses the same id", async () => { + const apiKeyId = "key-exact"; + const turn1 = await resolveConversationId({ + body: { model: "big-pickle", messages: [{ role: "user", content: "hi there" }] }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn1.isNewConversation, true); + + const turn2 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "hi there" }, + { role: "assistant", content: "hello!" }, + { role: "user", content: "tell me more" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn2.conversationId, turn1.conversationId); + assert.equal(turn2.isNewConversation, false); +}); + +test("resolveConversationId: prefix-match continuation across a longer history", async () => { + const apiKeyId = "key-prefix"; + const turn1 = await resolveConversationId({ + body: { model: "big-pickle", messages: [{ role: "user", content: "prefix test start" }] }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + // Turn 3 resends the full history including turn 2's exchange — still a + // continuation of turn 1's conversation even though it's grown further. + const turn3 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "prefix test start" }, + { role: "assistant", content: "ack" }, + { role: "tool", content: "tool result" }, + { role: "assistant", content: "done" }, + { role: "user", content: "and one more thing" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal(turn3.conversationId, turn1.conversationId); +}); + +test("resolveConversationId: an edited/duplicated mid-history turn mints its own independent conversation (2026-08-06 redesign — no forking)", async () => { + // The scenario that originally motivated the hash-chain rewrite, and now + // motivates the no-forking redesign: OpenClaw-style cache-aware context + // injection edits turn `c` to `c'` and duplicates turn `i` with an + // injected variant `i'` ahead of it, between two otherwise-related + // requests: + // request 1: a b c d e f g h i + // request 2: a b c' d e f g h i' i j k + // `a`/`b` are byte-identical, but every OmniRoute conversation is a single + // straight line — it never forks. So request 2 must become its OWN + // independent conversation (not request1's), with its OWN complete chain + // (a b c' d e f g h i' i j k), and request1's chain must stay untouched. + const apiKeyId = "key-fork"; + const request1 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + { role: "user", content: "c" }, + { role: "assistant", content: "d" }, + { role: "user", content: "e" }, + { role: "assistant", content: "f" }, + { role: "user", content: "g" }, + { role: "assistant", content: "h" }, + { role: "user", content: "i" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(request1.isNewConversation, true); + + const request2 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + { role: "user", content: "c'" }, + { role: "assistant", content: "d" }, + { role: "user", content: "e" }, + { role: "assistant", content: "f" }, + { role: "user", content: "g" }, + { role: "assistant", content: "h" }, + { role: "user", content: "i'" }, + { role: "assistant", content: "i" }, + { role: "user", content: "j" }, + { role: "assistant", content: "k" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + // A distinct, brand-new conversation — not request1's. + assert.notEqual(request2.conversationId, request1.conversationId); + assert.equal(request2.isNewConversation, true); + + // request1's chain is completely untouched: still exactly its own 9 turns. + const tree1 = getConversationTurnPage(request1.conversationId, { limit: 500 }).nodes; + assert.equal(tree1.length, 9); + assert.deepEqual( + tree1.map((n) => n.contentHash).sort(), + [ + hashOfPlainTextTurn("user", "a"), + hashOfPlainTextTurn("assistant", "b"), + hashOfPlainTextTurn("user", "c"), + hashOfPlainTextTurn("assistant", "d"), + hashOfPlainTextTurn("user", "e"), + hashOfPlainTextTurn("assistant", "f"), + hashOfPlainTextTurn("user", "g"), + hashOfPlainTextTurn("assistant", "h"), + hashOfPlainTextTurn("user", "i"), + ].sort() + ); + + // request2's chain is its own complete, independent 12-turn history — + // including its OWN copies of "a" and "b" (different node ids than + // request1's, since each conversation's chain hashing is scoped to its + // own conversation id), not references into request1's chain. + const tree2 = getConversationTurnPage(request2.conversationId, { limit: 500 }).nodes; + assert.equal(tree2.length, 12); + assert.deepEqual( + tree2.map((n) => n.contentHash).sort(), + [ + hashOfPlainTextTurn("user", "a"), + hashOfPlainTextTurn("assistant", "b"), + hashOfPlainTextTurn("user", "c'"), + hashOfPlainTextTurn("assistant", "d"), + hashOfPlainTextTurn("user", "e"), + hashOfPlainTextTurn("assistant", "f"), + hashOfPlainTextTurn("user", "g"), + hashOfPlainTextTurn("assistant", "h"), + hashOfPlainTextTurn("user", "i'"), + hashOfPlainTextTurn("assistant", "i"), + hashOfPlainTextTurn("user", "j"), + hashOfPlainTextTurn("assistant", "k"), + ].sort() + ); + + const ids1 = new Set(tree1.map((n) => n.id)); + const ids2 = new Set(tree2.map((n) => n.id)); + for (const id of ids2) { + assert.ok(!ids1.has(id), "the two conversations must not share any node ids"); + } + + // A repeat of request2's exact history continues request2 (not a THIRD + // conversation) — the redesign doesn't mint a new id on every retry of an + // already-diverged chain. + const request2Retry = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + { role: "user", content: "c'" }, + { role: "assistant", content: "d" }, + { role: "user", content: "e" }, + { role: "assistant", content: "f" }, + { role: "user", content: "g" }, + { role: "assistant", content: "h" }, + { role: "user", content: "i'" }, + { role: "assistant", content: "i" }, + { role: "user", content: "j" }, + { role: "assistant", content: "k" }, + { role: "user", content: "l" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(request2Retry.conversationId, request2.conversationId); + assert.equal(request2Retry.isNewConversation, false); +}); + +test("resolveConversationId: continuation is detected even when the system prompt is regenerated every turn (dynamic CLI boilerplate)", async () => { + // Real coding-agent CLIs (Claude Code, opencode, etc.) commonly regenerate + // the system prompt on EVERY request with live context (timestamp, cwd, + // git status...). The chain must exclude the system message entirely, or + // that volatility alone breaks continuation detection for real traffic — + // every turn would mint a brand new conversation id, even though + // apiKeyId/model/toolNames and the actual user/assistant history are + // unchanged. Discovered live on a real deployment (#9315 follow-up): 28 + // consecutive requests from one growing session, each with turn_count=1. + const apiKeyId = "key-volatile-system"; + const dynamicSystem = (n: number) => + `You are an agent. Current time: 2026-08-04T12:0${n}:00Z. cwd: /home/user/project`; + + const turn1 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "system", content: dynamicSystem(0) }, + { role: "user", content: "please fix the bug in foo.ts" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn1.isNewConversation, true); + + const turn2 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + // System prompt regenerated with a DIFFERENT timestamp — everything + // else (apiKeyId, model, tool set, actual conversation content) is + // identical/growing normally. + { role: "system", content: dynamicSystem(1) }, + { role: "user", content: "please fix the bug in foo.ts" }, + { role: "assistant", content: "Sure, I'll look at it." }, + { role: "user", content: "thanks, also check bar.ts" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal( + turn2.conversationId, + turn1.conversationId, + "expected turn2 to be recognized as a continuation despite the regenerated system prompt" + ); + assert.equal(turn2.isNewConversation, false); + + // The regenerated system prompt must never appear as a chain node. + const tree = getConversationTurnPage(turn1.conversationId, { limit: 500 }).nodes; + for (const node of tree) { + assert.notEqual(node.role, "system"); + } +}); + +test("resolveConversationId: continuation is detected even when the earliest turns rotate out of a sliding context window (live OpenClaw traffic pattern)", async () => { + // Discovered live on a real deployment: OpenClaw drops/summarizes the + // EARLIEST turns as a session grows (to bound context size), so the + // request's first non-system turn is a DIFFERENT piece of text on every + // single request — not just an edited/duplicated turn somewhere in the + // middle (that's the fork scenario above), but the very first turn the + // fingerprint bucket used to anchor on. If the bucket depends on that text + // at all, findAgenticConversationsByFingerprint returns zero candidates + // and the turn-chain match never even runs — the conversation looks + // "new" forever, the exact symptom this whole test file guards against. + const apiKeyId = "key-sliding-window"; + const toolNames = ["exec"]; + + const turn1 = await resolveConversationId({ + body: { + model: "big-pickle", + tools: [{ name: "exec" }], + messages: [ + { role: "user", content: "turn-A-oldest" }, + { role: "assistant", content: "turn-B" }, + { role: "user", content: "turn-C-shared-tail" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn1.isNewConversation, true); + + // Turn 2: the oldest turns ("turn-A-oldest", "turn-B") are gone, replaced + // by an unrelated summary — only "turn-C-shared-tail" onward survived. + const turn2 = await resolveConversationId({ + body: { + model: "big-pickle", + tools: [{ name: "exec" }], + messages: [ + { role: "user", content: "[context summary, unrelated to turn-A/turn-B text]" }, + { role: "user", content: "turn-C-shared-tail" }, + { role: "assistant", content: "turn-D" }, + { role: "user", content: "turn-E" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal( + turn2.conversationId, + turn1.conversationId, + "expected turn2 to be recognized as a continuation despite the first turn's text changing entirely" + ); + assert.equal(turn2.isNewConversation, false); + + // Confirmed via the fingerprint itself: identical apiKeyId/model/toolNames + // (the only inputs to computeFingerprintHash now) despite completely + // different message content between the two requests. + const fp1 = computeFingerprintHash({ apiKeyId, model: "big-pickle", toolNames }); + const fp2 = computeFingerprintHash({ apiKeyId, model: "big-pickle", toolNames }); + assert.equal(fp1, fp2); +}); + +test("resolveConversationId: continuation is detected even when the reconnect turn's content is duplicated earlier in the chain (tool-polling loop)", async () => { + // Discovered live: real agentic traffic (a tool-polling loop, "ack"/"poll" + // repeated many times — one real conversation had 28 byte-identical copies + // of a single turn) leaves MANY existing nodes sharing the same content + // hash. When a sliding context window means the new request's earliest + // retained turn is one of these repeated turns, findReconnectMatch must + // not just grab whichever occurrence happens to be tried first (the + // oldest, per SQLite's insertion-order return) — that stale occurrence's + // recorded next-turn differs from the new content, so it looks like a + // divergence even though the TRUE tail occurrence (no recorded child yet) + // would extend cleanly. This is what made a real conversation mint a + // brand-new copy of its entire history on every single request instead of + // ever reconnecting (2026-08-06). + const apiKeyId = "key-dup-content"; + + const turn1 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "start" }, + { role: "assistant", content: "a1" }, + { role: "user", content: "ack" }, + { role: "assistant", content: "poll" }, + { role: "user", content: "ack" }, + { role: "assistant", content: "poll" }, + { role: "user", content: "ack" }, + { role: "assistant", content: "poll" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + assert.equal(turn1.isNewConversation, true); + + // Sliding window: only the last "ack"/"poll" pair survived, followed by + // genuinely new content. "ack" and "poll" each match 3 existing nodes. + const turn2 = await resolveConversationId({ + body: { + model: "big-pickle", + messages: [ + { role: "user", content: "ack" }, + { role: "assistant", content: "poll" }, + { role: "user", content: "brand new turn" }, + ], + }, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal( + turn2.conversationId, + turn1.conversationId, + "expected turn2 to reconnect to turn1's conversation via the TRUE tail occurrence of the repeated ack/poll turns, not mint a new one" + ); + assert.equal(turn2.isNewConversation, false); + + const tree = getConversationTurnPage(turn1.conversationId, { limit: 500 }).nodes; + assert.equal( + tree.length, + 9, + "the new turn should be appended, not a whole new duplicate history" + ); + assert.ok(tree.some((n) => n.contentHash === hashOfPlainTextTurn("user", "brand new turn"))); +}); + +test("resolveConversationId: different api keys never merge, even with byte-identical content", async () => { + // Fingerprint isolation (apiKeyId is part of computeFingerprintHash) is + // the actual multi-tenant boundary — must hold regardless of the turn + // chain's own content-addressing. + const body = { model: "big-pickle", messages: [{ role: "user", content: "hi" }] }; + + const first = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId: "key-tenant-a", + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + const second = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId: "key-tenant-b", + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.notEqual(second.conversationId, first.conversationId); + + const fingerprintA = computeFingerprintHash({ + apiKeyId: "key-tenant-a", + model: "big-pickle", + toolNames: [], + }); + const fingerprintB = computeFingerprintHash({ + apiKeyId: "key-tenant-b", + model: "big-pickle", + toolNames: [], + }); + assert.notEqual(fingerprintA, fingerprintB); +}); + +test("resolveConversationId: a byte-identical repeat of a single-turn request continues the same conversation", async () => { + // Content-addressed nodes mean a byte-identical opener from the SAME + // apiKey/model (a client retry, or a genuinely separate session that also + // just says "hi") fully matches the existing 1-turn chain — nothing + // diverges (there's no turn afterward to disagree on yet), so this is a + // real continuation, not a fork candidate at all. + const apiKeyId = "key-repeated-singleshot"; + const body = { model: "big-pickle", messages: [{ role: "user", content: "hi" }] }; + + const first = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + const second = await resolveConversationId({ + body, + model: "big-pickle", + apiKeyId, + clientSessionIdHeader: null, + correlationId: nextCorrelationId(), + }); + + assert.equal(first.isNewConversation, true); + assert.equal(second.conversationId, first.conversationId); + assert.equal(second.isNewConversation, false); + + const tree = getConversationTurnPage(first.conversationId, { limit: 500 }).nodes; + assert.equal(tree.length, 1); +}); + +test("resolveConversationId: client-supplied X-Omniroute-Session-Id wins outright", async () => { + const headerValue = "client-pinned-session-abc"; + const first = await resolveConversationId({ + body: { model: "big-pickle", messages: [{ role: "user", content: "conversation A" }] }, + model: "big-pickle", + apiKeyId: "key-header", + clientSessionIdHeader: headerValue, + correlationId: nextCorrelationId(), + }); + assert.equal(first.conversationId, headerValue); + + // A second, otherwise-unrelated conversation sending the SAME header value + // merges under that one id — the header is authoritative, no heuristic + // check runs at all. + const second = await resolveConversationId({ + body: { model: "gpt-4o", messages: [{ role: "user", content: "conversation B, unrelated" }] }, + model: "gpt-4o", + apiKeyId: "key-header-2", + clientSessionIdHeader: headerValue, + correlationId: nextCorrelationId(), + }); + assert.equal(second.conversationId, headerValue); +}); + +// The old 8000-char text_preview truncation (and the JSON-validity-after- +// truncation concern it required) no longer applies: conversation_turn_nodes +// stores identity only, never turn text (migration 156) — display content is +// always resolved fresh, full and untruncated, from the call-log artifact +// (see conversationTurnContent.test.ts). diff --git a/tests/unit/conversationTurnContent.test.ts b/tests/unit/conversationTurnContent.test.ts new file mode 100644 index 0000000000..adccdf3a8c --- /dev/null +++ b/tests/unit/conversationTurnContent.test.ts @@ -0,0 +1,141 @@ +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"; + +// conversationTurnContent.ts resolves a conversation_turn_nodes row's actual +// display text/tool-call shape on demand from the call-log artifact its +// last_correlation_id points at (migration 156 dropped the old stored +// text_preview/block_kind/tool_name columns -- see conversationTracker.ts). + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-conv-turn-content-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const { hashTurnContent } = await import("../../open-sse/services/conversationTracker.ts"); +const { resolveTurnDisplayContent } = + await import("../../open-sse/services/conversationTurnContent.ts"); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +function insertCallLog(row: { id: string; correlationId: string; artifactRelPath: string | null }) { + const db = core.getDbInstance(); + db.prepare( + `INSERT INTO call_logs (id, timestamp, method, path, status, model, correlation_id, artifact_relpath) + VALUES (?, ?, 'POST', '/v1/chat/completions', 200, 'big-pickle', ?, ?)` + ).run(row.id, new Date().toISOString(), row.correlationId, row.artifactRelPath); +} + +function writeArtifact(relPath: string, clientRawRequestBody: unknown) { + const absPath = path.join(TEST_DATA_DIR, "call_logs", relPath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync( + absPath, + JSON.stringify({ + schemaVersion: 5, + requestBody: null, + responseBody: null, + error: null, + pipeline: { + clientRawRequest: { body: clientRawRequestBody }, + }, + }) + ); +} + +test("resolveTurnDisplayContent resolves plain text turns from the artifact's raw request body", () => { + insertCallLog({ id: "log-1", correlationId: "corr-1", artifactRelPath: "2026-01-01/log-1.json" }); + writeArtifact("2026-01-01/log-1.json", { + messages: [ + { role: "user", content: "hello there" }, + { role: "assistant", content: "hi!" }, + ], + }); + + const result = resolveTurnDisplayContent([{ lastCorrelationId: "corr-1" }]); + const userHash = hashTurnContent({ + role: "user", + text: "hello there", + blockKind: "text", + toolName: null, + }); + assert.deepEqual(result.get(userHash), { + textPreview: "hello there", + blockKind: "text", + toolName: null, + }); +}); + +test("resolveTurnDisplayContent resolves tool_use/tool_result shape, full and untruncated", () => { + const bigArgs = JSON.stringify({ path: "/tmp/big.md", content: "line\n".repeat(2000) }); + insertCallLog({ id: "log-2", correlationId: "corr-2", artifactRelPath: "2026-01-01/log-2.json" }); + writeArtifact("2026-01-01/log-2.json", { + input: [{ type: "function_call", name: "write", call_id: "c1", arguments: bigArgs }], + }); + + const result = resolveTurnDisplayContent([{ lastCorrelationId: "corr-2" }]); + const hash = hashTurnContent({ + role: "tool", + text: bigArgs, + blockKind: "tool_use", + toolName: "write", + }); + const content = result.get(hash); + assert.equal(content?.blockKind, "tool_use"); + assert.equal(content?.toolName, "write"); + // No 8000-char truncation anymore -- the full raw arguments string survives. + assert.equal(content?.textPreview, bigArgs); + assert.ok(content!.textPreview.length > 8000); +}); + +test("resolveTurnDisplayContent groups nodes by correlation id, reading each artifact once", () => { + insertCallLog({ id: "log-3", correlationId: "corr-3", artifactRelPath: "2026-01-01/log-3.json" }); + writeArtifact("2026-01-01/log-3.json", { + messages: [ + { role: "user", content: "a" }, + { role: "assistant", content: "b" }, + { role: "user", content: "c" }, + ], + }); + + const result = resolveTurnDisplayContent([ + { lastCorrelationId: "corr-3" }, + { lastCorrelationId: "corr-3" }, + { lastCorrelationId: "corr-3" }, + ]); + + for (const [role, text] of [ + ["user", "a"], + ["assistant", "b"], + ["user", "c"], + ] as const) { + const hash = hashTurnContent({ role, text, blockKind: "text", toolName: null }); + assert.equal(result.get(hash)?.textPreview, text); + } +}); + +test("resolveTurnDisplayContent skips nodes with no correlation id without throwing", () => { + const result = resolveTurnDisplayContent([{ lastCorrelationId: null }]); + assert.equal(result.size, 0); +}); + +test("resolveTurnDisplayContent omits content for an unresolvable correlation id (missing call_logs row, purged artifact, or no pipeline captured)", () => { + const missingRow = resolveTurnDisplayContent([{ lastCorrelationId: "corr-does-not-exist" }]); + assert.equal(missingRow.size, 0); + + insertCallLog({ id: "log-4", correlationId: "corr-4", artifactRelPath: null }); + const noArtifact = resolveTurnDisplayContent([{ lastCorrelationId: "corr-4" }]); + assert.equal(noArtifact.size, 0); + + insertCallLog({ + id: "log-5", + correlationId: "corr-5", + artifactRelPath: "2026-01-01/does-not-exist.json", + }); + const missingFile = resolveTurnDisplayContent([{ lastCorrelationId: "corr-5" }]); + assert.equal(missingFile.size, 0); +}); diff --git a/tests/unit/conversations-active-call-log-id.test.ts b/tests/unit/conversations-active-call-log-id.test.ts new file mode 100644 index 0000000000..9e8473f582 --- /dev/null +++ b/tests/unit/conversations-active-call-log-id.test.ts @@ -0,0 +1,81 @@ +/** + * Regression test for /api/conversations's `activeCallLogId` field. + * + * `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's + * INSERT needs duration/status/tokens, none of which exist yet while a reply + * is still streaming) — so `lastCallLogId` (joined from `call_logs`) always + * lags one request behind for a conversation with an in-flight reply. The + * conversation panel needs the CURRENT pending request's own id (tracked + * separately, in-memory, via usageHistory's pendingById) to poll its live + * partial text. This test proves the route surfaces that id, keyed off the + * pending request's `sessionTag` (== the conversation's own id). + */ + +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-conv-active-call-log-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const agenticConversations = await import("../../src/lib/db/agenticConversations.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const route = await import("../../src/app/api/conversations/route.ts"); + +test.after(() => { + core.resetDbInstance(); + usageHistory.clearPendingRequests(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test.beforeEach(() => { + usageHistory.clearPendingRequests(); +}); + +function seedTwoTurnConversation(id: string) { + agenticConversations.createAgenticConversation({ id, apiKeyId: null, fingerprintHash: "fp" }); + agenticConversations.insertConversationTurnNodes(id, null, [ + { id: `${id}-n1`, parentId: null, role: "user", contentHash: "h1" }, + { id: `${id}-n2`, parentId: `${id}-n1`, role: "assistant", contentHash: "h2" }, + ]); +} + +test("GET /api/conversations: surfaces the in-flight pending request's own id as activeCallLogId", async () => { + const conversationId = "conv_active_test_1"; + seedTwoTurnConversation(conversationId); + + const pendingId = usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", true, { + sessionTag: conversationId, + }); + assert.ok(pendingId, "trackPendingRequest should return the generated pending id"); + + const res = await route.GET(new Request("http://localhost/api/conversations?limit=50")); + assert.equal(res.status, 200); + const body = (await res.json()) as { + conversations: Array<{ id: string; isActive: boolean; activeCallLogId: string | null }>; + }; + + const row = body.conversations.find((c) => c.id === conversationId); + assert.ok(row, "seeded conversation should be present in the response"); + assert.equal(row!.isActive, true); + assert.equal(row!.activeCallLogId, pendingId); +}); + +test("GET /api/conversations: activeCallLogId is null for a conversation with no in-flight request", async () => { + const conversationId = "conv_active_test_2"; + seedTwoTurnConversation(conversationId); + + const res = await route.GET(new Request("http://localhost/api/conversations?limit=50")); + assert.equal(res.status, 200); + const body = (await res.json()) as { + conversations: Array<{ id: string; isActive: boolean; activeCallLogId: string | null }>; + }; + + const row = body.conversations.find((c) => c.id === conversationId); + assert.ok(row, "seeded conversation should be present in the response"); + assert.equal(row!.isActive, false); + assert.equal(row!.activeCallLogId, null); +}); diff --git a/tests/unit/conversations-tree-route-seq-param.test.ts b/tests/unit/conversations-tree-route-seq-param.test.ts new file mode 100644 index 0000000000..d01ff1df7f --- /dev/null +++ b/tests/unit/conversations-tree-route-seq-param.test.ts @@ -0,0 +1,30 @@ +/** + * Regression test for /api/conversations/[id]/tree's query-param parsing. + * + * Real bug: `Number(searchParams.get("beforeSeq"))` is 0 (not NaN) when the + * param is absent, since `Number(null) === 0`. That made an ABSENT + * beforeSeq/afterSeq look like "beforeSeq=0"/"afterSeq=0" was explicitly + * given, which — because the DB layer checks `opts.afterSeq != null` (true + * for 0) BEFORE checking limit — forced every single request into the + * uncapped "poll for new turns" branch, ignoring `limit` entirely and + * returning the conversation's ENTIRE history on every load. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { parseSeqParam } from "../../src/app/api/conversations/[id]/tree/route.ts"; + +test("parseSeqParam: an absent query param returns undefined, not 0", () => { + assert.equal(parseSeqParam(null), undefined); + assert.equal(parseSeqParam(""), undefined); +}); + +test("parseSeqParam: a real numeric string parses to that number, including a literal '0'", () => { + assert.equal(parseSeqParam("0"), 0); + assert.equal(parseSeqParam("42"), 42); +}); + +test("parseSeqParam: a non-numeric string returns undefined rather than NaN", () => { + assert.equal(parseSeqParam("not-a-number"), undefined); +}); diff --git a/tests/unit/cors/origins.test.ts b/tests/unit/cors/origins.test.ts index 8fe8e78fd2..00950aa097 100644 --- a/tests/unit/cors/origins.test.ts +++ b/tests/unit/cors/origins.test.ts @@ -284,4 +284,10 @@ describe("cors/origins.STATIC_CORS_HEADERS", () => { ); assert.match(STATIC_CORS_HEADERS["Access-Control-Allow-Methods"], /OPTIONS/); }); + + it("allows the generic managed-lease control headers", () => { + const allowedHeaders = STATIC_CORS_HEADERS["Access-Control-Allow-Headers"]; + assert.match(allowedHeaders, /X-OmniRoute-Lease-Owner/i); + assert.match(allowedHeaders, /X-OmniRoute-Lease-Generation/i); + }); }); diff --git a/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx b/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx index ca528c302c..bfd409768a 100644 --- a/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx +++ b/tests/unit/dashboard/edit-connection-modal-openai-store-toggle.test.tsx @@ -32,9 +32,8 @@ vi.mock("@/store/emailPrivacyStore", () => ({ default: () => ({ hidden: false, toggle: vi.fn() }), })); -const { default: EditConnectionModal } = await import( - "../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx" -); +const { default: EditConnectionModal } = + await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx"); let container: HTMLDivElement; let root: Root; diff --git a/tests/unit/dashboard/providers/components/providerCardWarningIndicators.test.tsx b/tests/unit/dashboard/providers/components/providerCardWarningIndicators.test.tsx new file mode 100644 index 0000000000..e051f46979 --- /dev/null +++ b/tests/unit/dashboard/providers/components/providerCardWarningIndicators.test.tsx @@ -0,0 +1,119 @@ +// #10261 — provider warning badges advertised interaction they did not implement: +// (1) the usage-risk `subscriptionRisk` indicator promised "click for details" +// (`providers.riskNotice.tooltip`) but was a bare with no onClick/role/dialog; +// (2) the connection warning-count badge exposed neither a `title` (reasons) nor any +// click affordance, even though the reasons already exist in +// `providerSpecificData.apiKeyHealth[]` (see EditConnectionModal.tsx). +// +// This is the permanent regression guard for both defects, extended (per the plan-file's +// implementation checkbox) to also assert KEYBOARD activation (focus + Enter), not just +// pointer click, for the interactive risk indicator. +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ProviderCard from "../../../../../src/app/(dashboard)/dashboard/providers/components/ProviderCard"; + +vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null })); +vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null })); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: () => {} }) })); + +describe("ProviderCard — #10261 warning indicator consistency", () => { + let container: HTMLDivElement | null = null; + let root: ReturnType | null = null; + + function renderCard(props: Record = {}) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => { + root!.render( + {}} + {...props} + /> + ); + }); + return { container: container!, root: root! }; + } + + afterEach(() => { + if (root) { + act(() => root!.unmount()); + root = null; + } + if (container) { + document.body.removeChild(container); + container = null; + } + }); + + it("usage-risk indicator advertised as 'click for details' opens an accessible dialog on click", () => { + const { container: el } = renderCard(); + const riskEl = el.querySelector('[aria-label*="click for details"]'); + expect(riskEl).toBeTruthy(); + expect(el.querySelector('[role="dialog"]')).toBeFalsy(); + act(() => { + riskEl!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const dialog = el.querySelector('[role="dialog"]'); + expect(dialog).toBeTruthy(); + // The dialog must not leak raw credential values. + expect(dialog!.textContent || "").not.toMatch(/sk-[a-zA-Z0-9]/); + }); + + it("usage-risk indicator is a real interactive control reachable and activatable by keyboard", () => { + const { container: el } = renderCard(); + const riskEl = el.querySelector('[aria-label*="click for details"]') as HTMLElement; + expect(riskEl).toBeTruthy(); + // Must be a real interactive element — a