diff --git a/.env.example b/.env.example index 4e6438b593..9527957b3d 100644 --- a/.env.example +++ b/.env.example @@ -1290,7 +1290,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup. # Update these when providers release new CLI versions to avoid blocks. -CLAUDE_USER_AGENT="claude-cli/2.1.219 (external, cli)" +CLAUDE_USER_AGENT="claude-cli/2.1.258 (external, cli)" # Disable the deterministic tool-name cloak applied on both Anthropic-bound paths # (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) — @@ -1524,6 +1524,15 @@ CURSOR_USER_AGENT="Cursor/3.4" # request into the browser-backed path. # OMNIROUTE_BROWSER_POOL=on # WEB_COOKIE_USE_BROWSER=0 +# Obscura (https://github.com/h4ckf0r0day/obscura) is the primary headless +# engine: a lightweight CDP server the pool and cloudflare-playground connect +# to before falling back to Chromium. Unset OBSCURA_BIN to auto-detect from +# PATH; set OBSCURA_CDP_ENDPOINT to reuse an already-running Obscura instead +# of spawning one; set OBSCURA_PORT to pin the spawned serve port. +# Used by: open-sse/services/obscura.ts +# OBSCURA_BIN= +# OBSCURA_CDP_ENDPOINT= +# OBSCURA_PORT= # ── Kimi Web (international kimi.ai Connect-RPC) ── # Used by: open-sse/executors/kimi-web.ts. Override the base/chat URLs only if diff --git a/AGENTS.md b/AGENTS.md index d0ac2952b2..6e7ad18f2f 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, 355 LLM providers, auto-fallback. +**OmniRoute** — unified AI proxy/router. One endpoint, 356 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 (168 migrations) | +| Database | `src/lib/db/` | SQLite domain modules (169 migrations) | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | MCP Server | `open-sse/mcp-server/` | 110 tools (45 canonical + memory/skill/GitHub/pool/gamification/plugin/Notion/Obsidian/local-corpus/RTK modules), 3 transports (stdio / SSE / Streamable HTTP), 33 scopes | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | diff --git a/CHANGELOG.md b/CHANGELOG.md index d6fd097613..bfcad514f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### ✨ New Features +- **feat(dashboard):** adaptive context-budget dial on the compression settings panel — mode (`off` / `floor` / `replace-autotrigger`) and policy (`reserve-output` / `percentage` / `absolute`) persist via `PUT /api/settings/compression` `contextBudget`. Completes the dashboard half of #7005 (API + DB already shipped in #7183). - **feat(sse): STRICT_ZERO_COST** — opt-in, off-by-default `freeAccessPolicy: "strict"` setting that hard-verifies every auto-combo candidate against live quota state and per-connection economic safety before it can be dispatched, going beyond `hidePaidModels`'s static catalog diff --git a/README.md b/README.md index 303b9e12b6..1471960181 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@ # 🚀 OmniRoute — The Free AI Gateway -OmniRoute — Never stop coding. Every AI tool → 355 providers — 150+ 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. 355 AI providers · 150+ free tiers · ~1.50B free tokens/mo · 19 routing strategies · $0 to start. - +OmniRoute — Never stop coding. Every AI tool → 356 providers — 150+ 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. 356 AI providers · 150+ free tiers · ~1.50B free tokens/mo · 19 routing strategies · $0 to start.
@@ -20,7 +19,6 @@ > Stacking free tiers by hand is painful — dozens of SDKs, dozens of rate limits, and no idea how much you actually have. OmniRoute catalogs **451 free-tier entries across 35 recurring pool keys** and computes the token headline from the **17 pools with a published positive monthly budget plus five per-model Groq caps**, deduplicated by shared pool. The result stays visible on the dashboard (`/dashboard/free-tiers`). OmniRoute free-tier budget card: ~1.50B free tokens per month steady, up to ~2.13B in the first month with signup credits, from 35 documented recurring pool keys covering 451 cataloged free-tier entries behind one endpoint. Honest pool-deduped math — each shared pool counted once, including 17 recurring pools with a published positive monthly token budget plus five per-model Groq caps; 13 providers are marked avoid in the terms-risk catalog so you decide. Budget bar includes Mistral 1B, Nara 210M, LLM7 150M, Groq 30M (five per-model caps) and smaller pools, plus first-month signup credits and permanently-free no-token-cap providers surfaced separately so they never inflate the headline. Live used/remaining on /dashboard/free-tiers. - > Animated summary of the live `/dashboard/free-tiers` page. Full methodology (pool dedupe, credit tiers, provider terms): **[docs/reference/FREE_TIERS.md](docs/reference/FREE_TIERS.md)**. > > These figures are re-audited every two weeks against the live catalog and **move both ways** — a provider ends a free tier and the number drops; a new one lands and it climbs. We publish what the catalog actually computes, never a rounded-up best case. @@ -209,7 +207,7 @@ curl http://localhost:20128/v1/chat/completions \
-The Promise — One endpoint and 355 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 355 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files. +The Promise — One endpoint and 356 providers. Automatic fallback keeps routing while another healthy target is available. Six pillars: resilient fallback across 356 providers · up to 95% token savings on eligible workloads · $0 to start with 150+ free tiers and 53 recurring/keyless free-forever providers · 36 CLI/agent integrations through one config · OpenAI, Claude, Gemini and Responses API compatibility at /v1 · production controls including circuit breakers, TLS stealth, MCP 110 tools, A2A, memory, guardrails, evals and 39,000+ static test declarations across 5,100+ tracked test files.

@@ -462,7 +460,7 @@ All **19** strategies — mix & match per combo step: -What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 355 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. +What sets OmniRoute apart — a dated feature snapshot vs 9router, OpenRouter, CLIProxyAPI and LiteLLM across 13 capabilities. OmniRoute: 356 providers, 150+ free tiers built in, 19 routing strategies, 12-engine token compression, built-in MCP server with 110 tools, A2A agent protocol, persistent memory, guardrails, cloud agents, TLS fingerprint stealth, Desktop/Termux/PWA and 42 i18n UI locales. OmniRoute is MIT-licensed and self-hostable. Competitor capabilities and counts may change; see the linked methodology. 📊 Full methodology & per-feature detail vs 9router, OpenRouter, CLIProxyAPI & LiteLLM → [`docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md`](docs/comparison/OMNIROUTE_VS_ALTERNATIVES.md) @@ -649,7 +647,6 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) > **352 registered providers** across the canonical chat, media, search, local, cloud-agent and system collections, including **152 carrying `hasFree: true` discovery metadata**. The chat model registry covers **229 providers / 2,554 distinct provider-model pairs / 1,283 raw model IDs**; the separate free-budget catalog has **451 per-model rows**, **35 recurring pools** and **53 recurring/keyless free-forever providers**. These are different denominators by design; definitions and pool-deduped calculations live in the [Provider Reference](docs/reference/PROVIDER_REFERENCE.md) and [Free Tiers](docs/reference/FREE_TIERS.md). -
### 🏢 Every major lab — through one endpoint @@ -724,6 +721,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) 📦 npm (global)npm install -g omnirouteOne command, any OS 🐳 Dockerdocker run … diegosouzapw/omnirouteMulti-arch AMD64 + ARM64 🖥️ Desktop (Electron)npm run electron:buildNative window + system tray — Windows / macOS / Linux + 🎩 Menu-bar (OmniRouteTray)brew install --cask zoispag/tap/omniroute-traySupervises & auto-updates the server — macOS 💪 ARMnative arm64Raspberry Pi, ARM servers, Apple Silicon 📱 Android (Termux)pkg install nodejs && npx -y omnirouteRuns on your phone, 24/7, no root 📲 PWA"Add to Home Screen"Fullscreen, offline, installable from browser @@ -732,7 +730,7 @@ of your shell history. → [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) 🛠️ From sourcenpm install && npm run devHack on it, contribute -📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md) +📖 [Docker Guide](docs/guides/DOCKER_GUIDE.md) · [Desktop](electron/README.md) · [Menu-bar tray](https://github.com/zoispag/omniroute-tray) · [Termux](docs/guides/TERMUX_GUIDE.md) · [PWA](docs/guides/PWA_GUIDE.md) · [OpenCode](docs/frameworks/OPENCODE.md)
@@ -767,6 +765,42 @@ From inside the editor: open the **Extensions** view, search **"OmniRoute"**, cl
+### 🎩 New: OmniRouteTray — your gateway, living in the menu bar + +
+ +> `omniroute serve` is happiest when it's always on. **[OmniRouteTray](https://github.com/zoispag/omniroute-tray)** +> turns that into a set-and-forget menu-bar app for macOS: it starts the server, keeps it alive +> across reboots, updates it in place, and puts your live token budget one click away — **no +> terminal window left open, no `npm install -g omniroute` to babysit.** + +Built with [Tauri v2](https://v2.tauri.app/) (a Rust core the size of a rounding error), it ships +its own signed Node 24 runtime and manages an app-owned OmniRoute install, so it never fights your +global `node`/`bun`. It **shares your existing `~/.omniroute/` config and database** — so it's the +same OmniRoute you already run, just with a hat on. 🎩 + + + + + + + + +
What it doesHow
🟢 Supervises the serverSpawns omniroute serve, adopts an already-running instance instead of duplicating it
📊 Live usage at a glanceProvider quota bars, Claude session/weekly limits with reset countdowns, 30-day cost breakdown
🔄 Auto-updates in placeStaged install, atomic swap, rollback on failure — always on the newest release
🚀 Start on loginOptional launch at login; tray-only, no dock icon
🩺 Doctor & logsOne-click diagnostics and server log access
+ +```sh +brew install --cask zoispag/tap/omniroute-tray +``` + +Prefer a download? Grab the latest .dmg from +Releases. Source, issues and build +docs live at zoispag/omniroute-tray. +
💛 A community project by @zoispag — not an official OmniRoute release.
+ +
+ +
+ ## 🔒 Private & Local-First
@@ -1207,7 +1241,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi 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) — 122 domain modules, 168 migrations + Databasebetter-sqlite3 (SQLite, WAL journaling) + LowDB (JSON legacy) — 122 domain modules, 169 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) @@ -1270,8 +1304,7 @@ Métricas canônicas em 2026-08-24: **1.029 vídeos únicos** · **11.132.922 vi Resilience GuideCircuit breakers, cooldowns, queue, anti-thundering herd, TLS spoofing Auto-Combo Engine16-factor scoring, mode packs, self-healing Proxy Guide3-level proxy system, 1proxy marketplace, registry CRUD - Free TiersConsolidated directory: 35 documented recurring pools / 451 cataloged free-tier entries - Features GalleryVisual dashboard tour with screenshots + Free TiersConsolidated directory: 35 documented recurring pools / 451 cataloged free-tier entries Features GalleryVisual dashboard tour with screenshots Codebase DocumentationBeginner-friendly codebase walkthrough diff --git a/changelog.d/features/11786-seekai-provider.md b/changelog.d/features/11786-seekai-provider.md new file mode 100644 index 0000000000..ad4e2dbd44 --- /dev/null +++ b/changelog.d/features/11786-seekai-provider.md @@ -0,0 +1 @@ +- **feat(providers):** add SeekAi (`seekai.cc`) as an OpenAI-compatible New-API gateway — catalog id `seekai` (alias `ska`), `https://seekai.cc/v1`, live `/v1/models` via `passthroughModels`, aggregator-list membership so New-API balance detection can opt in. No referral/aff codes. ([#11786](https://github.com/diegosouzapw/OmniRoute/issues/11786)) diff --git a/changelog.d/features/12071-csv-import-errors.md b/changelog.d/features/12071-csv-import-errors.md new file mode 100644 index 0000000000..096eed4ef6 --- /dev/null +++ b/changelog.d/features/12071-csv-import-errors.md @@ -0,0 +1 @@ +- **feat(providers):** import-from-file modal shows per-row API errors and ships a downloadable CSV template ([#12071](https://github.com/diegosouzapw/OmniRoute/issues/12071)) diff --git a/changelog.d/features/12108-provider-search-name-baseurl.md b/changelog.d/features/12108-provider-search-name-baseurl.md new file mode 100644 index 0000000000..a13f2a12a1 --- /dev/null +++ b/changelog.d/features/12108-provider-search-name-baseurl.md @@ -0,0 +1 @@ +- **feat(providers):** dashboard search matches connection name and `baseUrl` so imported OpenAI-compat nodes surface on the provider card ([#12108](https://github.com/diegosouzapw/OmniRoute/issues/12108)) diff --git a/changelog.d/features/12306-headroom-url.md b/changelog.d/features/12306-headroom-url.md new file mode 100644 index 0000000000..7478e094f8 --- /dev/null +++ b/changelog.d/features/12306-headroom-url.md @@ -0,0 +1 @@ +- **feat(settings):** persist `headroomUrl` through Settings so status/start use the operator URL instead of only `HEADROOM_URL` ([#12306](https://github.com/diegosouzapw/OmniRoute/issues/12306)) diff --git a/changelog.d/fixes/12312-grok-cli-supergrok-quota.md b/changelog.d/fixes/12312-grok-cli-supergrok-quota.md new file mode 100644 index 0000000000..1fed5df39e --- /dev/null +++ b/changelog.d/fixes/12312-grok-cli-supergrok-quota.md @@ -0,0 +1 @@ +- **fix(grok-cli):** treat omitted SuperGrokPro `creditUsagePercent` as 0% used so Provider Limits still renders a weekly bar (proto3 zero-elision) ([#12312](https://github.com/diegosouzapw/OmniRoute/pull/12312)) — thanks @HouMinXi diff --git a/changelog.d/fixes/12325-generic-quota-429-invalidate.md b/changelog.d/fixes/12325-generic-quota-429-invalidate.md new file mode 100644 index 0000000000..753e96027d --- /dev/null +++ b/changelog.d/fixes/12325-generic-quota-429-invalidate.md @@ -0,0 +1 @@ +- **fix(quota):** drop the generic quota cache (agy / Antigravity / Claude OAuth) on an upstream 429 so reset-aware scoring does not keep a 60s stale snapshot, and force-refresh the next usage fetch so inner provider caches cannot recache the same window ([#12325](https://github.com/diegosouzapw/OmniRoute/pull/12325)) — thanks @HouMinXi diff --git a/changelog.d/fixes/12581-basereds-licenses-a2a-lifecycle.md b/changelog.d/fixes/12581-basereds-licenses-a2a-lifecycle.md new file mode 100644 index 0000000000..8dbb345f96 --- /dev/null +++ b/changelog.d/fixes/12581-basereds-licenses-a2a-lifecycle.md @@ -0,0 +1 @@ +- **fix(ci):** document MIT exceptions for `@eloqnt/{config,format-json,format-po}` (next-intl transitive; locked tarballs omit `license`) and keep the A2A lifecycle vitest off the real SQLite persistence seam ([#12581](https://github.com/diegosouzapw/OmniRoute/issues/12581)) diff --git a/changelog.d/maintenance/12402-claude-code-2-1-258.md b/changelog.d/maintenance/12402-claude-code-2-1-258.md new file mode 100644 index 0000000000..6b5ba0b108 --- /dev/null +++ b/changelog.d/maintenance/12402-claude-code-2-1-258.md @@ -0,0 +1 @@ +- **chore(providers):** bump the Claude Code wire identity and the Devin bridge image pin from `2.1.220` to `2.1.258` ([#12402](https://github.com/diegosouzapw/OmniRoute/pull/12402)) — thanks @ggiak diff --git a/changelog.d/maintenance/12554-electron-44.md b/changelog.d/maintenance/12554-electron-44.md new file mode 100644 index 0000000000..41b5e50f53 --- /dev/null +++ b/changelog.d/maintenance/12554-electron-44.md @@ -0,0 +1 @@ +- **chore(electron):** upgrade the desktop app to Electron 44 (Chromium 152, Node 24.18.1) ([#12217](https://github.com/diegosouzapw/OmniRoute/pull/12217)). **Requires macOS 13 (Ventura) or later** — Chromium dropped macOS 12 (Monterey), so Monterey users must stay on an earlier OmniRoute desktop build. Windows and Linux are unaffected; the app already shipped only x64/arm64, so Electron 44 dropping 32-bit builds changes nothing. Removes the `openAsHidden`/`wasOpenedAsHidden` login-item fields deleted in Electron 44 — hidden autostart continues to work through the `--hidden` argument registered with the login item ([#12554](https://github.com/diegosouzapw/OmniRoute/pull/12554)) diff --git a/changelog.d/maintenance/hartmark-batch-filesize.md b/changelog.d/maintenance/hartmark-batch-filesize.md new file mode 100644 index 0000000000..d55f5b4029 --- /dev/null +++ b/changelog.d/maintenance/hartmark-batch-filesize.md @@ -0,0 +1 @@ +- **chore(quality):** rebaseline the file-size caps the hartmark batch grew past (`combos/page.tsx` via [#12355](https://github.com/diegosouzapw/OmniRoute/pull/12355), `open-sse/services/combo.ts` via [#12338](https://github.com/diegosouzapw/OmniRoute/pull/12338)) diff --git a/changelog.d/maintenance/houminxi-batch-filesize.md b/changelog.d/maintenance/houminxi-batch-filesize.md new file mode 100644 index 0000000000..0d3426ed51 --- /dev/null +++ b/changelog.d/maintenance/houminxi-batch-filesize.md @@ -0,0 +1 @@ +- **chore(quality):** rebaseline the file-size caps the HouMinXi batch grew past when its PRs stacked (`providers/page.tsx`, `chatCore.ts`, `accountFallback.ts`) — each PR measured correctly in isolation, none saw the stacking diff --git a/config/quality/.license-allowlist.json b/config/quality/.license-allowlist.json index f2cca50374..c74ee066e4 100644 --- a/config/quality/.license-allowlist.json +++ b/config/quality/.license-allowlist.json @@ -74,6 +74,24 @@ "justification": "CC-BY-4.0 applies to the caniuse browser-support data (a dataset, not code). The Creative Commons Attribution license requires attribution when distributing — OmniRoute does not distribute caniuse-lite data directly to end users; it is consumed by browserslist/PostCSS at build time to generate CSS compatibility info. This is a widely accepted pattern in the Node.js ecosystem (caniuse-lite is in millions of projects). Attribution is satisfied by keeping the package in node_modules with its original license file.", "risk": "low", "reviewAt": "v4.0.0" + }, + "@eloqnt/config": { + "license": "MIT", + "justification": "Transitive of next-intl (MIT). npm registry SPDX for the @eloqnt scope is MIT; @eloqnt/config@0.1.0 republished with license: MIT. The locked 0.0.2 tarball (next-intl's ^0.0.2 range, which is 0.0.x only) omits both package.json#license and a LICENSE file, so license-checker reports UNKNOWN. Same author (Jan Amann / amannn). OmniRoute does not modify the package. Re-review when next-intl bumps the range to a release that ships the license field.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "@eloqnt/format-json": { + "license": "MIT", + "justification": "Same as @eloqnt/config: next-intl transitive, registry SPDX MIT, locked 0.0.3 tarball omits license field and LICENSE file so the checker reports UNKNOWN. Re-review with the next-intl range bump.", + "risk": "low", + "reviewAt": "v4.0.0" + }, + "@eloqnt/format-po": { + "license": "MIT", + "justification": "Same as @eloqnt/config: next-intl transitive, registry SPDX MIT, locked 0.0.3 tarball omits license field and LICENSE file so the checker reports UNKNOWN. Re-review with the next-intl range bump.", + "risk": "low", + "reviewAt": "v4.0.0" } } } diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 90f949af47..e10d1f6551 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -220,11 +220,6 @@ "count": 26 } }, - "open-sse/handlers/chatCore/clientUsageBuffer.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "open-sse/handlers/chatCore/executorHelpers.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -608,11 +603,6 @@ "count": 1 } }, - "open-sse/services/providerCostData.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "open-sse/services/rateLimitManager.ts": { "@typescript-eslint/no-unused-vars": { "count": 2 @@ -763,7 +753,7 @@ }, "open-sse/utils/cursorAgentProtobuf.ts": { "@typescript-eslint/no-unused-vars": { - "count": 3 + "count": 2 } }, "open-sse/utils/earlyStreamKeepalive.ts": { @@ -1722,16 +1712,6 @@ "count": 2 } }, - "src/lib/oneproxyRotator.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, - "src/lib/oneproxySync.ts": { - "@typescript-eslint/no-unused-vars": { - "count": 1 - } - }, "src/lib/piiSanitizer.ts": { "@typescript-eslint/no-unused-vars": { "count": 1 @@ -4812,7 +4792,7 @@ }, "tests/unit/responses-translation-fixes.test.ts": { "@typescript-eslint/no-explicit-any": { - "count": 35 + "count": 34 } }, "tests/unit/route-edge-coverage.test.ts": { diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index c4e3bd4361..c39d3d5d41 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,7 @@ { + "_rebaseline_2026_09_03_moonshot_native_quota": "PR feat/moonshot-native-quota own growth on release/v3.8.51: src/lib/db/migrationRunner.ts 1201->1206 (+5, case 172 retroactive guard for daily_quota_reset_* columns); src/sse/handlers/chat.ts 2434->2450 (+16, registerMoonshotQuotaFetcher + startup node scan at the existing quota-fetcher registration chokepoint); src/sse/services/auth.ts 3427->3450 (+23, resolveDailyResetForProvider + dailyReset arg on checkFallbackError); open-sse/services/accountFallback.ts 2422->2461 (+39, compatible-node credits_exhausted carve-out + TPD node-clock lock); tests/unit/account-fallback-service.test.ts 2008->2056 (+48, TPD/empty-wallet cases). Wiring at existing chokepoints; Moonshot host predicates, daily reset clock, and the balance fetcher live in new leaves under cap. Covered by tests/unit/moonshot-*.test.ts + account-fallback-service.test.ts (135/135 focused).", + "_rebaseline_2026_09_02_11786_seekai_provider": "PR #11786 (feat/11786-seekai-provider, closes #11786) own growth: src/shared/constants/providers/apikey/gateways.ts 1438->1458 (check-file-size split-newline=1459; the seekai APIKEY_PROVIDERS_GATEWAYS catalog entry plus authHint, additive data at the existing registry chokepoint, same god-file no-split rationale as prior gateways.ts rebaselines: #10987 logfare, #10531 freebuff). Covered by tests/unit/seekai-provider.test.ts.", + "_rebaseline_2026_09_02_12325_generic_429_invalidate": "PR #12325 own growth: open-sse/handlers/chatCore.ts 5946->5955 (+9 = the non-Codex 429 else-if that drops the generic quota wrapper and stamps force-refresh, plus a source-regex breadcrumb). Irreducible call-site wiring next to the existing Codex 429 invalidateCodexQuotaCache branch; not extractable without splitting handleChatCore mid-response. Covered by tests/unit/generic-quota-fetcher.test.ts (31/31) and tests/unit/antigravity-429-quota-cooldown.test.ts.", "_rebaseline_2026_09_02_12429_wreq_migration_suite": "PR #12429 (wreq-js web-cookie transport): new test file tests/unit/tls-client-wreq-migration.test.ts at 1374 lines, above the 1200 new-file testCap. Frozen rather than split: it is the single cohesive regression suite for the transport migration (31 cases covering streaming, fragmented EOF sentinels, proxy isolation, first-byte and hard deadlines, binary responses and cancellation), and the cases share the native-transport harness the file sets up once. Splitting it during a merge would duplicate that harness across files for no coverage gain. Entered at the exact LOC, so it can only ratchet down from here.", "_rebaseline_2026_09_02_12239_chatgpt_web_cleanroom": "PR #12239 (backryun, codex/restore-chatgpt-web-cleanroom) own growth at the two existing chat chokepoints for the clean-room ChatGPT Web transport: src/sse/handlers/chat.ts 2384->2424 (+40); open-sse/handlers/chatCore.ts 5946->5976 (+30). Additive dispatch wiring; the retirement guard is narrowed to the GPL-derived cgpt-web alias rather than removed, so #11754's provenance decision still holds for the old implementation. Same own-growth rationale as _rebaseline_2026_08_20_10531_freebuff_provider.", "_rebaseline_2026_09_02_12412_grok_web_prettier": "PR #12412 (repository Prettier style applied to tests/unit/grok-web.test.ts): the reformat expands the file +277 lines (2436 -> 2713) with an identical parsed AST — no production code, no assertion changes. Cap set to 2985 rather than the exact 2713 on the operator's instruction (2026-09-02): ~10% headroom so routine additions to this suite do not re-trip the gate on formatting alone. Previous cap 2437. This is a deliberate exception to the down-only ratchet for one reformatted test file; every other entry keeps the #12411 tightening.", @@ -204,7 +207,7 @@ "_rebaseline_basered_codebuddy_cn": "Base-red fix (#4664 CodeBuddy CN): oauth-providers-config.test.ts 867->870 (+3) to align the EXPECTED provider list/config with the codebuddy-cn provider that #4664 added to the registry without updating this test (it asserts 'exactly once').", "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", "tests/integration/chat-pipeline.test.ts": 1644, - "tests/unit/account-fallback-service.test.ts": 2008, + "tests/unit/account-fallback-service.test.ts": 2056, "tests/unit/batch_api.test.ts": 1345, "tests/unit/cc-compatible-provider.test.ts": 1225, "tests/unit/chatcore-translation-paths.test.ts": 3447, @@ -412,14 +415,14 @@ "open-sse/executors/codex.ts": 1499, "open-sse/executors/cursor.ts": 1759, "open-sse/executors/muse-spark-web.ts": 1405, - "open-sse/handlers/chatCore.ts": 5976, + "open-sse/handlers/chatCore.ts": 5984, "open-sse/handlers/imageGeneration.ts": 3259, "open-sse/handlers/search.ts": 1789, "open-sse/mcp-server/schemas/tools.ts": 1621, "open-sse/mcp-server/server.ts": 1572, - "open-sse/services/accountFallback.ts": 2422, + "open-sse/services/accountFallback.ts": 2467, "open-sse/services/adobeFireflyBrowserLogin.ts": 1401, - "open-sse/services/combo.ts": 4023, + "open-sse/services/combo.ts": 4036, "open-sse/translator/response/openai-responses.ts": 1466, "open-sse/utils/cursorAgentProtobuf.ts": 1547, "open-sse/utils/proxyFetch.ts": 1271, @@ -428,15 +431,15 @@ "open-sse/vendor/codex-chatgpt-web/bridge.ts": 1322, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1344, "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": 3186, - "src/app/(dashboard)/dashboard/combos/page.tsx": 5012, + "src/app/(dashboard)/dashboard/combos/page.tsx": 5018, "src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": 1319, "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2491, "src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1631, - "src/app/(dashboard)/dashboard/providers/page.tsx": 2007, + "src/app/(dashboard)/dashboard/providers/page.tsx": 2025, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1201, "src/app/(dashboard)/dashboard/settings/components/ProxyRegistryManager.tsx": 1475, "src/app/(dashboard)/dashboard/settings/components/ResilienceTab.tsx": 1271, - "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1606, + "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx": 1607, "src/app/(dashboard)/dashboard/settings/components/SystemStorageTab.tsx": 1597, "src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2152, "src/app/api/providers/[id]/models/route.ts": 2432, @@ -445,14 +448,14 @@ "src/app/docs/lib/openapi.generated.ts": 1347, "src/lib/db/apiKeys.ts": 1610, "src/lib/db/core.ts": 1745, - "src/lib/db/migrationRunner.ts": 1201, + "src/lib/db/migrationRunner.ts": 1206, "src/lib/tailscaleTunnel.ts": 1208, "src/lib/tokenHealthCheck.ts": 1218, "src/shared/components/RequestLoggerV2.tsx": 1718, - "src/shared/constants/providers/apikey/gateways.ts": 1439, + "src/shared/constants/providers/apikey/gateways.ts": 1459, "src/shared/services/cliRuntime.ts": 1296, - "src/sse/handlers/chat.ts": 2424, - "src/sse/services/auth.ts": 3427, + "src/sse/handlers/chat.ts": 2450, + "src/sse/services/auth.ts": 3450, "tests/unit/account-fallback-service.test.ts": 2453, "tests/unit/provider-validation-specialty.test.ts": 4656 }, @@ -629,5 +632,9 @@ "_rebaseline_2026_06_30_v3842_release_chatgptweb_compression": "v3.8.42 cycle-close file-size reconciliation (DRIFT measured OK on each PR's base, stacked above frozen at the merge tip; fast-path PR->release/** does not run check:file-size). (1) open-sse/executors/chatgpt-web.ts 2870->3206 (+336 = #5531 portable SHA3-512 sentinel-PoW wiring with the native-vs-fallback digest path + #5536 GPT-5.5 Pro handoff branch; the pure Keccak-f[1600] fallback itself already lives in the separate leaf open-sse/utils/sha3-512.ts — the executor growth is the cohesive call-site/handoff logic, not extractable without hiding the sentinel chokepoint). (2) tests/unit/chatgpt-web.test.ts 2855->3159 (+304 = #5536 GPT-5.5 Pro handoff coverage; pair-file with its executor). (3) open-sse/services/compression/strategySelector.ts 997->1022 (+25 = #5527 T02 honest default-on pipeline inflation guard wiring at the existing finalizeStackedResult choke). All cohesive at existing chokepoints; covered by tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts, chatgpt-web.test.ts (GPT-5.5 Pro), compression-pipeline-inflation-guard.test.ts.", "open-sse/executors/chatgpt-web.ts": "3241", "_rebaseline_2026_08_30_11771_vercel_gateway_passthrough": "PR #11771 adds passthroughModels: true (1 line) to the Vercel AI Gateway registry entry — no split available, single-line provider-config addition.", - "_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy)." + "_relax_velocity_2026_08_30": "127 frozen line caps and cap/testCap raised by 20% (velocity phase; see quality-baseline.json _policy).", + "_rebaseline_2026_09_02_12325_merge_v3851": "Merge of release/v3.8.51 into #12325. Both sides grew chatCore.ts at the same chokepoint: #12239 took it 5946->5976 upstream, and this PR adds its +9 non-Codex 429 branch on top. check-file-size.mjs counts split(\"\\\\n\").length (trailing-newline empty element), so the merged file is 5981. The cap is the merged LOC, not either side alone; no other entry moves.", + "_rebaseline_2026_09_03_houminxi_batch_stacked": "Crescimento medido DEPOIS que os 9 PRs da leva HouMinXi entraram, quando cada um empilhou sobre o rebaseline do anterior: providers/page.tsx 2007->2025 (+18 = feedback de erro por linha do import CSV do #12504 somado a busca por nome/baseUrl do #12495, ambos no mesmo painel de conexoes); chatCore.ts 5981->5984 (+3 = o #12325 invalida o cache generico de quota no 429 upstream, ao lado do ramo Codex ja existente); accountFallback.ts 2461->2467 (+6 = o #12566 empilha a carve-out de familia Antigravity sobre o rebaseline 2422->2461 que o #12590 registrou para o carve-out credits_exhausted da Moonshot; os dois tocam checkFallbackError). Cada PR mediu certo isoladamente, mas nenhum enxergava o empilhamento. Fiacao em chokepoints existentes. NAO cobre codex.ts nem stream.ts, que ja violavam no tip antes desta leva (drift da base).", + "_rebaseline_2026_09_03_12604_claude_code_2_1_258": "PR #12604 (bump da wire identity do Claude Code 2.1.220->2.1.258, commits do @ggiak vindos do #12402) crescimento proprio: src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx 1606->1607 (+1, a linha do seletor que acompanha a nova versao de identidade). Uma linha num painel de settings ja existente; nao ha o que extrair. Coberto por client-identity-profiles e claude-codex-identity-version-sync (138/138 focados).", + "_rebaseline_2026_09_03_hartmark_batch": "Leva hartmark (#12293 #12355 #12447 #12445 #12446 #12460 #12461 #12338 #12448) crescimento proprio, medido no tip com os nove mergeados: src/app/(dashboard)/dashboard/combos/page.tsx 5012->5018 (+6, #12355 impede que a falha de bundling do tiktoken de um provider sem relacao derrube /api/providers, e o painel passa a lidar com o estado degradado); open-sse/services/combo.ts 4023->4036 (+13, #12338 nos fixes do universal-handoff: nota de bare-fallback, escopo por mesma requisicao e log da falha silenciosa). Fiacao em chokepoints existentes do roteamento de combo. NAO cobre codex.ts nem stream.ts, ja violando no tip antes desta leva (drift da base)." } diff --git a/docker/devin-bridge/Dockerfile b/docker/devin-bridge/Dockerfile index 3fd2a30dba..895455cf9f 100644 --- a/docker/devin-bridge/Dockerfile +++ b/docker/devin-bridge/Dockerfile @@ -1,6 +1,6 @@ FROM node:26.0.0-bookworm-slim -ARG CLAUDE_CODE_VERSION=2.1.220 +ARG CLAUDE_CODE_VERSION=2.1.258 ARG DEVIN_CLI_VERSION=3000.2.17 ARG TARGETARCH diff --git a/docker/devin-bridge/compose.yml b/docker/devin-bridge/compose.yml index c850414dbc..1a4ab8884b 100644 --- a/docker/devin-bridge/compose.yml +++ b/docker/devin-bridge/compose.yml @@ -28,7 +28,7 @@ x-runtime: &runtime context: ../.. dockerfile: docker/devin-bridge/Dockerfile args: - CLAUDE_CODE_VERSION: 2.1.220 + CLAUDE_CODE_VERSION: 2.1.258 DEVIN_CLI_VERSION: 3000.2.17 user: "10001:10001" read_only: true diff --git a/docs/DEVIN_CLAUDE_BRIDGE.md b/docs/DEVIN_CLAUDE_BRIDGE.md index 4a6d56d370..30b3bf3d4c 100644 --- a/docs/DEVIN_CLAUDE_BRIDGE.md +++ b/docs/DEVIN_CLAUDE_BRIDGE.md @@ -4,16 +4,22 @@ Messages endpoint while the official Devin CLI supplies model responses over ACP stdio. It does not modify the existing Anthropic, Claude OAuth, Claude Web, or `devin-cli` providers. -> **Current status: offline and live validated.** The pinned Claude Code `2.1.220` completed -> three isolated scenarios through Devin CLI `3000.2.17` and model -> `swe-1-7-lightning`. The final live run proved client-owned `Read`, `Edit`, and `Bash` -> turns, successful `npm test` results, project command and skill discovery, Devin-only -> routing, and zero Claude egress. +> **Current status: pinned Claude Code `2.1.258`; offline and live validation last recorded +> on `2.1.220`.** The `2.1.220` pin completed three isolated scenarios through Devin CLI +> `3000.2.17` and model `swe-1-7-lightning`; that final live run proved client-owned `Read`, +> `Edit`, and `Bash` turns, successful `npm test` results, project command and skill +> discovery, Devin-only routing, and zero Claude egress. The pin was then raised to `2.1.258` +> (the CLI generation OmniRoute's Claude identity impersonates, and the first line that ships +> the Fable 5.1 tier natively). On the new pin the install layer and `claude --version` were +> verified on the pinned base image, and the bridge unit suite, `compose config` and the +> static isolation proof pass — but the offline mock scenario and the live three-scenario +> suite have not been re-run yet. Re-run them (see "Updating pinned tools") before relying on +> the bridge with this pin. ## Architecture ```text -Claude Code 2.1.220 (isolated non-root Linux container) +Claude Code 2.1.258 (isolated non-root Linux container) -> http://omniroute:20128/v1/messages -> devin-cli-agentic (Claude-format, no-auth provider) -> devin acp --agent-type summarizer (official ACP stdio, no Devin tools) diff --git a/docs/architecture/QUALITY_GATES.md b/docs/architecture/QUALITY_GATES.md index a3bca79c6b..4b4315cc4d 100644 --- a/docs/architecture/QUALITY_GATES.md +++ b/docs/architecture/QUALITY_GATES.md @@ -90,17 +90,17 @@ Runs on every PR to `main`. Blocks merge on failure. Runs after `test-coverage`. Blocks merge on failure. -| Script | Validates | Blocking | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | -| `quality:collect` | Emits `quality-metrics.json` (ESLint warning count, coverage from merged shard report) | Yes (upstream of ratchet) | -| `quality:ratchet` | Each metric in `quality-baseline.json` has not regressed (ESLint warnings ≤ baseline; coverage ≥ baseline) | Yes | -| `check:duplication` | Code duplication (jscpd@4) does not exceed baseline in `quality-baseline.json` | Yes | -| `check:complexity` | File-level cyclomatic complexity does not exceed the cap (core ESLint `complexity` + `max-lines-per-function`) | Yes | -| `check:cognitive-complexity` | Cognitive complexity ratchet (`eslint-plugin-sonarjs`) — separate ESLint pass; CI runs both merged as the single `check:complexity-ratchets` step | Yes | -| `check:dead-code` | Unused exports / files ratchet (knip) does not regress vs baseline | Yes | -| `check:compression-budget` | Compression benchmark budget — per-engine token-savings floors must not regress | Yes | -| `check:type-coverage` | Percent-typed ratchet (`type-coverage`) does not regress; largely subsumes `typecheck:noimplicit:core` | Yes | -| `check:codeql-ratchet` | Open CodeQL alert count does not regress (reads via `gh api`; graceful-skip without token) | Yes | +| Script | Validates | Blocking | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| `quality:collect` | Emits `quality-metrics.json` (ESLint warning count, coverage from merged shard report) | Yes (upstream of ratchet) | +| `quality:ratchet` | Each metric in `quality-baseline.json` has not regressed (ESLint warnings ≤ baseline; coverage ≥ baseline) | Yes | +| `check:duplication` | Code duplication (jscpd@4) does not exceed baseline in `quality-baseline.json` | Yes | +| `check:complexity` | File-level cyclomatic complexity does not exceed the cap (core ESLint `complexity` + `max-lines-per-function`) | Yes | +| `check:cognitive-complexity` | Cognitive complexity ratchet (`eslint-plugin-sonarjs`) — separate ESLint pass; CI runs both merged as the single `check:complexity-ratchets` step | Yes | +| `check:dead-code` | Unused exports / files ratchet (knip) does not regress vs baseline | Yes | +| `check:compression-budget` | Compression benchmark budget — per-engine token-savings floors must not regress | Yes | +| `check:type-coverage` | Percent-typed ratchet (`type-coverage`) does not regress; largely subsumes `typecheck:noimplicit:core` | Yes | +| `check:codeql-ratchet` | Open CodeQL alert count does not regress (reads via `gh api`; graceful-skip without token) — refresh cadence and manual trigger: see "CodeQL ratchet" below | Yes | ### Job: `quality-extended` @@ -324,6 +324,36 @@ Commit this file alongside the change that improved the metric. A PR that improv metric without updating the baseline will be caught by `--require-tighten` (Fase 6A.5, pending implementation). +### CodeQL ratchet: refresh cadence and manual trigger + +`check:codeql-ratchet` reads **repo state, refreshed on a schedule — not per PR.** +`gh api repos/diegosouzapw/OmniRoute/code-scanning/default-setup` reports +`state: configured`, `schedule: weekly`: GitHub's default-setup scan, not a per-push +analysis. Consequence: after a PR that FIXES alerts merges, the ratchet keeps reading +the old, higher count until the next scheduled scan runs — so it reports a regression +on every open PR, including the fixing PR's own follow-ups, until the scan catches up. + +**Manual refresh**: `gh workflow run codeql.yml --ref release/vX.Y.Z` re-runs the +analysis and republishes alerts within minutes. Read `.github/workflows/codeql.yml` +first — its header explains it is `workflow_dispatch`-only **because it conflicts with +GitHub's "default setup"** (`CodeQL analyses from advanced configurations cannot be +processed when the default setup is enabled`). Restoring `push`/`pull_request`/ +`schedule` triggers requires an **owner action first**: Settings → Code security → +CodeQL: Default → Advanced. Do not add a `schedule:` trigger without that switch — it +will only produce failing runs. + +**Tighten the baseline after the count drops** — `node scripts/check/check-codeql-ratchet.mjs +--update` writes the new measured count into `quality-baseline.json` → +`metrics.codeqlAlerts.value`, so the ratchet does not silently permit a regression back +up to the old ceiling. Worked example (2026-09-02/03): PR #12502 fixed 7 real alerts +(13 → 6 measured open); PR #12530 tightened the frozen baseline 11 → 6 to match; the +remaining 6 were then dismissed with per-alert justification down to 0 open. + +**Dismissals are the operator's call (Hard Rule #14)** — never dismiss a CodeQL alert +without recording the technical justification in the dismissal comment: `won't fix` for +an upstream-protocol requirement, `used in tests` for a test fixture, `false positive` +for a sanitizer CodeQL cannot see (precedent: `docs/security/ERROR_SANITIZATION.md`). + --- ## Test Retry Policy (WS5.4, v3.8.49) diff --git a/docs/compression/COMPRESSION_GUIDE.md b/docs/compression/COMPRESSION_GUIDE.md index 8f51281fd6..bd2a9e665a 100644 --- a/docs/compression/COMPRESSION_GUIDE.md +++ b/docs/compression/COMPRESSION_GUIDE.md @@ -309,7 +309,7 @@ Every compressed request includes stats in the server logs: | Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped | | Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped | | Phase 4 | Output Styles, SLM-tier Ultra, eval harness | ✅ Shipped | -| Phase 4C | Adaptive context-budget ("dial") — compute engine + API (`contextBudget` on `PUT /api/settings/compression`) | ✅ Shipped (API-configurable; dashboard controls not yet built, #7005) | +| Phase 4C | Adaptive context-budget ("dial") — compute engine + API (`contextBudget` on `PUT /api/settings/compression`) + dashboard mode/policy controls | ✅ Shipped | --- diff --git a/docs/diagrams/cli-terminal.svg b/docs/diagrams/cli-terminal.svg index 139a868898..a16f88351f 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 3bc3895f25..92fe11718c 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 f32198f62f..41bcdf397c 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. 355 providers. Never stop building — OmniRoute picks the cheapest one that works. + One endpoint. 356 providers. Never stop building — OmniRoute picks the cheapest one that works. @@ -38,7 +38,7 @@ Never hit limits - Auto-fallback across 355 providers in + Auto-fallback across 356 providers in milliseconds. Quota out? The next provider takes over while a healthy target remains. diff --git a/docs/diagrams/readme-hero.svg b/docs/diagrams/readme-hero.svg index 944176fcc5..7959f33521 100644 --- a/docs/diagrams/readme-hero.svg +++ b/docs/diagrams/readme-hero.svg @@ -1,5 +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. + 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 +27,7 @@ Never stop coding. - Every AI tool → 355 providers150+ free — through one endpoint. + Every AI tool → 356 providers150+ free — through one endpoint. Claude Code · Codex · Cursor · Cline · Copilot · Antigravity  →  FREE Claude / GPT / Gemini · auto-fallback diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 72acc05bfc..ceed44c426 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -122,6 +122,8 @@ Access via: WhatsApp, Telegram, Slack, Discord, iMessage, Signal... ## 📖 Provider Setup +To bulk-add API-key connections from a CSV or JSON file, use **Dashboard → Providers → Import from file**. Columns are positional (`provider,name,apiKey,baseUrl,priority`); `provider` must already exist as a managed provider or a compatible node. See [Import providers from a CSV or JSON file](../providers/CSV-IMPORT.md). + ### 🔐 Subscription Providers #### Claude Code (Pro/Max) diff --git a/docs/i18n/ar/llm.txt b/docs/i18n/ar/llm.txt index abd2becf64..9f405eda91 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/az/llm.txt b/docs/i18n/az/llm.txt index 83aae20b40..74a600ba34 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bg/llm.txt b/docs/i18n/bg/llm.txt index 19b6eaa3bc..0db6d8a4ce 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/bn/llm.txt b/docs/i18n/bn/llm.txt index ded0a119f8..a7417f642e 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/cs/llm.txt b/docs/i18n/cs/llm.txt index d7d495ce79..c8b5c31922 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/da/llm.txt b/docs/i18n/da/llm.txt index fc0f3956f9..3d9663c28f 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/de/llm.txt b/docs/i18n/de/llm.txt index 0a3d6f41df..0c8572e4f4 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/es/llm.txt b/docs/i18n/es/llm.txt index a14c3364ca..75a6488e37 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fa/llm.txt b/docs/i18n/fa/llm.txt index 2637cb93e6..cdcccf2fc8 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fi/llm.txt b/docs/i18n/fi/llm.txt index dcdbce4e9b..66d62309f4 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/fr/llm.txt b/docs/i18n/fr/llm.txt index 4ec20698a0..dabd29e87f 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/gu/llm.txt b/docs/i18n/gu/llm.txt index bb96a9ca15..21dad6d8f9 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/he/llm.txt b/docs/i18n/he/llm.txt index 7b05f7da36..4174e2aa0f 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hi/llm.txt b/docs/i18n/hi/llm.txt index d5abf815fd..b08baf5189 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/hu/llm.txt b/docs/i18n/hu/llm.txt index 242f219733..13109da6fc 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/id/llm.txt b/docs/i18n/id/llm.txt index 6ea1e4a22c..77df7b4c16 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/it/llm.txt b/docs/i18n/it/llm.txt index d3d4caf7c0..b479c853cd 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ja/llm.txt b/docs/i18n/ja/llm.txt index fbd4266e07..a1528c646b 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ko/llm.txt b/docs/i18n/ko/llm.txt index d72d39d120..4d8508fd12 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/mr/llm.txt b/docs/i18n/mr/llm.txt index d3d06a534b..8ba71d98f5 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ms/llm.txt b/docs/i18n/ms/llm.txt index fb92e9f704..3903f94cf1 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/nl/llm.txt b/docs/i18n/nl/llm.txt index 108465e6ee..8db8eb985b 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/no/llm.txt b/docs/i18n/no/llm.txt index 92ef34cc68..e635301c86 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/phi/llm.txt b/docs/i18n/phi/llm.txt index ed4c534f3b..b760a56857 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pl/llm.txt b/docs/i18n/pl/llm.txt index 9c2d022df6..e683cdb76c 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt-BR/llm.txt b/docs/i18n/pt-BR/llm.txt index 1d6c34ce26..49a62961e7 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/pt/llm.txt b/docs/i18n/pt/llm.txt index 5be880273e..9362022f86 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ro/llm.txt b/docs/i18n/ro/llm.txt index a9006002d2..fd21c2042b 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ru/llm.txt b/docs/i18n/ru/llm.txt index fb3bd9901f..d68314887e 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sk/llm.txt b/docs/i18n/sk/llm.txt index 30a439beb7..666d1a570e 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sv/llm.txt b/docs/i18n/sv/llm.txt index a3e29a0308..74238247ae 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/sw/llm.txt b/docs/i18n/sw/llm.txt index 3e50fc5415..5f129a8263 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ta/llm.txt b/docs/i18n/ta/llm.txt index 26cc42048a..d42fa346a0 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/te/llm.txt b/docs/i18n/te/llm.txt index 95b13b644a..6ca040018f 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/th/llm.txt b/docs/i18n/th/llm.txt index 0c144efb4f..9884ab3b21 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/tr/llm.txt b/docs/i18n/tr/llm.txt index 9c5ce3e607..77b9eadb1b 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/uk-UA/llm.txt b/docs/i18n/uk-UA/llm.txt index 407ee0fdfa..3a7bc01f36 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/ur/llm.txt b/docs/i18n/ur/llm.txt index b80c1a22cc..1cdd0e6bb5 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/vi/llm.txt b/docs/i18n/vi/llm.txt index 8aaea70f4e..3e28435b0a 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-CN/llm.txt b/docs/i18n/zh-CN/llm.txt index 2a9812d5f2..d11163a83a 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/i18n/zh-TW/llm.txt b/docs/i18n/zh-TW/llm.txt index 2caf3c753c..56256a53da 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -128,7 +128,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -281,7 +281,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -393,7 +393,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -437,7 +437,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2f83d88080..16a39eef8d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2297,6 +2297,7 @@ paths: post: tags: [Providers] summary: Auto-detect and import the local Antigravity CLI (agy) login from disk + x-always-protected: true responses: "200": description: Created or updated provider connection @@ -4050,12 +4051,14 @@ paths: get: tags: [CLI Tools] summary: Get Codex profiles + x-always-protected: true responses: "200": description: Codex profile list post: tags: [CLI Tools] summary: Create Codex profile + x-always-protected: true requestBody: required: true content: @@ -4068,6 +4071,7 @@ paths: put: tags: [CLI Tools] summary: Update Codex profile + x-always-protected: true requestBody: required: true content: @@ -4080,6 +4084,7 @@ paths: delete: tags: [CLI Tools] summary: Delete Codex profile + x-always-protected: true responses: "200": description: Profile deleted @@ -9751,6 +9756,7 @@ paths: tags: - Logs summary: "GET logs › export" + x-always-protected: true responses: "200": description: OK @@ -10230,6 +10236,7 @@ paths: tags: - Providers summary: "POST providers › › claude auth › apply local" + x-always-protected: true responses: "200": description: OK @@ -10238,6 +10245,7 @@ paths: tags: - Providers summary: "POST providers › › claude auth › export" + x-always-protected: true responses: "200": description: OK @@ -10246,6 +10254,7 @@ paths: tags: - Providers summary: "POST providers › › codex auth › apply local" + x-always-protected: true responses: "200": description: OK @@ -10254,6 +10263,7 @@ paths: tags: - Providers summary: "POST providers › › codex auth › export" + x-always-protected: true responses: "200": description: OK diff --git a/docs/providers/AGENTROUTER.md b/docs/providers/AGENTROUTER.md index aa28834c75..ea375f43c9 100644 --- a/docs/providers/AGENTROUTER.md +++ b/docs/providers/AGENTROUTER.md @@ -130,7 +130,7 @@ request (see `open-sse/services/claudeCodeCompatible.ts`): | Header | Value | | ------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `Authorization` | `Bearer ` | -| `User-Agent` | `claude-cli/2.1.219 (external, sdk-cli)` | +| `User-Agent` | `claude-cli/2.1.258 (external, sdk-cli)` | | `anthropic-version` | `2023-06-01` | | `anthropic-beta` | `claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24` | | Per-connection redact-thinking beta toggle | Adds `redact-thinking-2026-02-12` for upstreams that specifically require redacted thinking streams | diff --git a/docs/providers/CSV-IMPORT.md b/docs/providers/CSV-IMPORT.md new file mode 100644 index 0000000000..4f701a16a2 --- /dev/null +++ b/docs/providers/CSV-IMPORT.md @@ -0,0 +1,43 @@ +--- +title: "Import providers from a CSV or JSON file" +--- + +# Import providers from a CSV or JSON file + +Dashboard → Providers → **Import from file** creates API-key connections from a CSV or JSON list. Each row can target a different provider. Partial failure is the contract: valid rows still import when others fail, and the modal lists why the failed rows were rejected. + +This import does **not** create new OpenAI/Anthropic-compatible endpoint nodes. Create those first (Dashboard → Providers → Add OpenAI-Compatible, or `omniroute nodes add`), then import rows whose `provider` column is that node's id. A per-row `baseUrl` can still override the node's URL. + +## CSV (positional) + +Column names are cosmetic. The parser splits each row and destructures by index: + +| Index | Field | Required | Notes | +| ----- | ----- | -------- | ----- | +| 0 | `provider` | yes | Existing managed provider id (`openai`, `anthropic`, …) **or** an already-registered OpenAI/Anthropic-compatible **node** id | +| 1 | `name` | yes | Connection display name | +| 2 | `apiKey` | yes | API key | +| 3 | `baseUrl` | no | Per-row URL override | +| 4 | `priority` | no | Integer 1–100 | + +A first line whose first column is the literal word `provider` (any case) is skipped as a header. Blank lines and `#` comments are skipped. + +Download a starter file from the import modal (**Download CSV template**). Example: + +```csv +# OmniRoute provider import (positional columns) +provider,name,apiKey,baseUrl,priority +openai,Prod OpenAI,sk-your-openai-key,,1 +``` + +A made-up id such as `openai-compatible-chat-001` is not a node. The API returns `Unknown or unsupported provider` for that row; the modal shows it next to the row name. + +## JSON + +A JSON array of objects with the same fields (`provider`, `name`, `apiKey`, `baseUrl?`, `priority?`). Unlike CSV, JSON keys are named. + +```json +[ + { "provider": "openai", "name": "Prod OpenAI", "apiKey": "sk-your-openai-key", "priority": 1 } +] +``` diff --git a/docs/providers/meta.json b/docs/providers/meta.json index fa6485dd57..b5eca33685 100644 --- a/docs/providers/meta.json +++ b/docs/providers/meta.json @@ -8,6 +8,7 @@ "AGENTROUTER", "ZED-DOCKER", "CURSOR-DOCKER", - "CURSOR-API-KEY-AND-CLI" + "CURSOR-API-KEY-AND-CLI", + "CSV-IMPORT" ] } diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index ace16cf6c9..056a37beb9 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -622,7 +622,7 @@ process.env[`${PROVIDER_ID}_USER_AGENT`] | Variable | Default Value | When to Update | | -------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.219 (external, cli)` | When Anthropic releases a new CLI version | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.258 (external, cli)` | When Anthropic releases a new CLI version | | `CLAUDE_DISABLE_TOOL_NAME_CLOAK` | `false` | `executors/base.ts` + `executors/cliproxyapi.ts` | Set to `1`/`true` to forward third-party harness tool names verbatim to Anthropic on both Anthropic-bound paths (native OAuth and CLIProxyAPI). By default the executor deterministically aliases non-Claude-Code tool names (Claude Code canonical mapping where one exists, otherwise PascalCase) and reverses them on the response via `_toolNameMap`, so harnesses with snake_case tools are not refused as fingerprinted third-party clients. Debugging only. | | `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` | When OpenAI updates the Codex CLI | | `CODEX_CLIENT_VERSION` | `0.131.0` | Override Codex client version independently of full UA string | @@ -776,6 +776,9 @@ REQUEST_TIMEOUT_MS (global override) | `OMNIROUTE_NOTION_TLS_TIMEOUT_MS` | `30000` | Native wreq-js request timeout (`notionTlsClient.ts`); `notion-web` raises it per request to `180000` for long generations. | | `OMNIROUTE_NOTION_TLS_GRACE_MS` | `10000` | Absolute JS hard-deadline grace added on top of the native timeout. | | `OMNIROUTE_BROWSER_POOL` | `on` | Shared Playwright browser pool for browser-backed web-cookie chat (`browserPool.ts`); set `off` to disable. | +| `OBSCURA_BIN` | `auto-detect` | Path to the `obscura` binary used as the primary engine by the browser pool and Cloudflare Playground executor (`open-sse/services/obscura.ts`); auto-detected from the system PATH when unset. | +| `OBSCURA_CDP_ENDPOINT` | _(unset)_ | Point at an already-running Obscura (`http://host:port`) instead of spawning one; the module does not own that process (`open-sse/services/obscura.ts`). | +| `OBSCURA_PORT` | `random free port` | Explicit port for the spawned `obscura serve`; a free port is chosen automatically when unset (`open-sse/services/obscura.ts`). | | `WEB_COOKIE_USE_BROWSER` | `0` | Opt a web-cookie chat request into the browser-backed path (`browserBackedChat.ts`); `1` to enable. | | `KIMI_WEB_BASE_URL` | `https://www.kimi.ai` | Base URL for the Kimi Web (international kimi.ai Connect-RPC) executor (`kimi-web.ts`); override only for mirror/proxy endpoints. | | `KIMI_WEB_CHAT_URL` | `/apiv2/kimi.gateway.chat.v1.ChatService/Chat` | Full chat endpoint for the Kimi Web executor (`kimi-web.ts`). | diff --git a/docs/reference/PROVIDER_REFERENCE.md b/docs/reference/PROVIDER_REFERENCE.md index c577652c8c..6ddd59d202 100644 --- a/docs/reference/PROVIDER_REFERENCE.md +++ b/docs/reference/PROVIDER_REFERENCE.md @@ -1,16 +1,16 @@ --- title: "Provider Reference" version: 3.8.51 -lastUpdated: 2026-09-02 +lastUpdated: 2026-09-03 --- # 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-09-02 +> **Last generated:** 2026-09-03 -Total providers: **355**. See category breakdown below. +Total providers: **356**. See category breakdown below. ## Categories @@ -118,7 +118,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `zai-web` | `zw` | Z.ai Web | Web cookie | [link](https://chat.z.ai) | Copy the "token" value from chat.z.ai → DevTools → Application → Local Storage. Do not copy cookies; OmniRoute handles the per-request CAPTCHA through its browser transport. | — | | `zenmux-free` | `zmf` | ZenMux Free (Web) | Web cookie | [link](https://zenmux.ai) | Login at zenmux.ai, then export all cookies using EditThisCookie or Cookie-Editor and paste the full Cookie header string here. Refresh every ~30 days. | — | -## API Key Providers (paid / paid-with-free-credits) (237) +## API Key Providers (paid / paid-with-free-credits) (238) | ID | Alias | Name | Tags | Website | Notes | |----|-------|------|------|---------|-------| @@ -310,6 +310,7 @@ Use the dashboard at `/dashboard/providers` to enable, configure, and test each | `sarvam` | `sarvam` | Sarvam AI | API key | [link](https://docs.sarvam.ai) | ₹1,000 in free signup credits — never expire | | `scaleway` | `scw` | Scaleway AI | API key | [link](https://www.scaleway.com/en/docs/ai-data/generative-apis/) | 1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B | | `sealion` | `sealion` | SEA-LION | API key | [link](https://sea-lion.ai) | Sign in at sea-lion.ai with Google (no card, no region wall), create an API key, then paste it here. | +| `seekai` | `ska` | SeekAi | API key, aggregator | [link](https://seekai.cc) | Create an API key at https://seekai.cc, then paste it here as a Bearer token. | | `segmind` | `segmind` | Segmind | API key, image, video | [link](https://segmind.com) | Use your Segmind API key in the x-api-key header. OmniRoute targets https://api.segmind.com/v1/ and returns the generated image/video bytes directly. | | `sensenova` | `sensenova` | SenseNova | API key | [link](https://platform.sensenova.cn) | Get API key at platform.sensenova.cn | | `siliconflow` | `siliconflow` | SiliconFlow | API key | [link](https://cloud.siliconflow.com) | $1 free credits plus currently listed $0 models after identity verification; availability and limits may change | diff --git a/docs/reference/REMOVED_PROVIDERS.md b/docs/reference/REMOVED_PROVIDERS.md index e03283bd43..24c0aab7d4 100644 --- a/docs/reference/REMOVED_PROVIDERS.md +++ b/docs/reference/REMOVED_PROVIDERS.md @@ -1,3 +1,9 @@ +--- +title: "Removed Providers" +version: 3.8.51 +lastUpdated: 2026-09-03 +--- + # Providers removed at their operator's request Some services were integrated into OmniRoute and later removed because the people who run diff --git a/docs/security/STEALTH_GUIDE.md b/docs/security/STEALTH_GUIDE.md index 456a558b0c..767eb90a0b 100644 --- a/docs/security/STEALTH_GUIDE.md +++ b/docs/security/STEALTH_GUIDE.md @@ -117,8 +117,8 @@ Applied to: `system` blocks, all `messages[].content`, and `tools[].description` For third-party Anthropic relays that only accept "real Claude Code" traffic: -- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.220 (external, sdk-cli)"` -- `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"` +- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.258 (external, sdk-cli)"` +- `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.112.1"` - `CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v26.3.0"` - `anthropic-beta = "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24"` by default - The per-connection "Enable redact-thinking beta" toggle adds `redact-thinking-2026-02-12` when a CC Compatible upstream specifically requires redacted thinking streams @@ -241,7 +241,7 @@ All MITM endpoints require management auth (`requireCliToolsAuth`). The sudo pas | Variable | Default | | ------------------------ | --------------------------------------------------------------- | -| `CLAUDE_USER_AGENT` | `claude-cli/2.1.220 (external, cli)` | +| `CLAUDE_USER_AGENT` | `claude-cli/2.1.258 (external, cli)` | | `CODEX_USER_AGENT` | `codex-cli/0.149.0 (Windows 10.0.26200; x64)` | | `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` | | `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0` | diff --git a/electron/lib/windowLifecycle.js b/electron/lib/windowLifecycle.js index 8a75a4a18d..cea524ab37 100644 --- a/electron/lib/windowLifecycle.js +++ b/electron/lib/windowLifecycle.js @@ -1,11 +1,11 @@ /** Pure helpers for deciding and driving the Electron dashboard window lifecycle. */ -function shouldStartHidden({ argv = [], loginItemSettings = {} } = {}) { - return ( - argv.includes("--hidden") || - argv.includes("--minimized") || - loginItemSettings.wasOpenedAsHidden === true - ); +// Electron 44 removed `openAsHidden`/`wasOpenedAsHidden` from +// `app.set/getLoginItemSettings()` (they only ever worked on macOS 12 and below, which +// Electron 44 no longer supports). The hidden-autostart contract is now carried solely by +// the `--hidden` argument registered with the login item. +function shouldStartHidden({ argv = [] } = {}) { + return argv.includes("--hidden") || argv.includes("--minimized"); } function showOrCreateWindow({ appReady, getWindow, createWindow }) { diff --git a/electron/main.js b/electron/main.js index 19f232226b..2010fe5683 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1113,7 +1113,6 @@ function setupIpcHandlers() { try { app.setLoginItemSettings({ openAtLogin: true, - openAsHidden: true, args: ["--hidden"], }); return true; @@ -1153,7 +1152,6 @@ app.whenReady().then(async () => { !isHeadless && shouldStartHidden({ argv: process.argv, - loginItemSettings: app.getLoginItemSettings(), }); keepAliveWithoutWindows = startHidden; diff --git a/electron/package-lock.json b/electron/package-lock.json index 1aeabc7edc..8eb9f2634a 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -12,7 +12,7 @@ "electron-updater": "^6.8.9" }, "devDependencies": { - "electron": "^43.4.1", + "electron": "^44.0.0", "electron-builder": "^26.15.3" }, "engines": { @@ -1367,9 +1367,9 @@ } }, "node_modules/electron": { - "version": "43.4.1", - "resolved": "https://registry.npmjs.org/electron/-/electron-43.4.1.tgz", - "integrity": "sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==", + "version": "44.0.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-44.0.0.tgz", + "integrity": "sha512-FkTqPrFPZYljdPI5b7KORGsJTd6FgUQDefl5MrU3Xz9R87pAj9JLreIjDqcRN8hJIkFHIou0o8kKzvcpT9qiRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1575,9 +1575,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { diff --git a/electron/package.json b/electron/package.json index d4fd2bd1b2..1ed7410a4d 100644 --- a/electron/package.json +++ b/electron/package.json @@ -28,7 +28,7 @@ "electron-updater": "^6.8.9" }, "devDependencies": { - "electron": "^43.4.1", + "electron": "^44.0.0", "electron-builder": "^26.15.3" }, "overrides": { diff --git a/llm.txt b/llm.txt index 9789ad5c22..3748b08432 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 355 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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 356 AI providers — all through a single OpenAI-compatible endpoint. Includes a built-in MCP Server (110 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.22.2 <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, 168 migrations) +- **Database:** SQLite via better-sqlite3 (local, zero-config, 169 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 @@ -124,7 +124,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo │ │ │ ├── secrets.ts # Secrets management │ │ │ ├── stateReset.ts # State reset utilities │ │ │ ├── migrationRunner.ts # Schema migration runner -│ │ │ └── migrations/ # 168 versioned SQL migration files +│ │ │ └── migrations/ # 169 versioned SQL migration files │ │ ├── evals/ # Eval runner and scheduler │ │ ├── memory/ # Persistent conversational memory │ │ │ ├── extraction.ts # Memory extraction from conversations @@ -277,7 +277,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo ## Key Features (v3.8.50) ### Core Proxy -- **355 AI providers** with automatic format translation +- **356 AI providers** with automatic format translation - **Provider categories**: Free (90+ free tiers), OAuth, API Key, Self-Hosted, Custom (OpenAI/Anthropic-compatible) - **19 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, cache-optimized, context-relay, fusion, pipeline - **4-tier fallback**: Subscription → API Key → Cheap → Free @@ -389,7 +389,7 @@ diagnostics) plus **memory**, **skill**, **agentSkill**, **githubSkill**, **pool 8. **ProviderIcon component:** Unified icon system using `@lobehub/icons` (130+ SVG) with PNG fallback and generic icon fallback chain. Used on providers, dashboard, and agents pages. -9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 168 SQL migrations. +9. **DB architecture:** `localDb.ts` is a re-export layer only — real logic lives in 122 `src/lib/db/` modules with 169 SQL migrations. 10. **Upstream headers:** Custom headers merged in executors after default auth; same header name replaces executor value. Forbidden header names in `src/shared/constants/upstreamHeaders.ts`. @@ -433,7 +433,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 (122 domain-specific files, 168 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 (122 domain-specific files, 169 migrations). `localDb.ts` is re-exports only — add new functions to the proper `db/*.ts` module. 6. **Tests** use Node.js built-in test runner + Vitest. Run `npm test`. Vitest for MCP/autoCombo (`npm run test:vitest`). Playwright for E2E (`npm run test:e2e`). Coverage gate: ratchet vs `quality-baseline.json`, absolute floor 60% statements/lines/functions/branches. diff --git a/open-sse/config/claudeCodeCompatibleIdentity.ts b/open-sse/config/claudeCodeCompatibleIdentity.ts index 31b7997f2a..b614eea324 100644 --- a/open-sse/config/claudeCodeCompatibleIdentity.ts +++ b/open-sse/config/claudeCodeCompatibleIdentity.ts @@ -9,15 +9,19 @@ export const CLAUDE_CODE_COMPATIBLE_VERSION = CLAUDE_CODE_CLIENT_VERSION; export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = getClaudeCodeUserAgent("sdk-cli"); export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = CLAUDE_CODE_SDK_PACKAGE_VERSION; export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = CLAUDE_CODE_RUNTIME_VERSION; -const CONTEXT_1M_NATIVE_MODELS = ["claude-opus-5"]; +const CONTEXT_1M_NATIVE_MODELS = ["claude-fable-5-1", "claude-opus-5"]; export function modelHasNativeContext1m(model: string | null | undefined): boolean { const normalizedModel = String(model || "") .trim() .toLowerCase() + .replace(/^.*?(?=claude-)/, "") .replace(/-\d{8}$/, ""); return CONTEXT_1M_NATIVE_MODELS.some( - (supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`) + (supported) => + normalizedModel === supported || + (normalizedModel.startsWith(`${supported}-`) && + !/^\d/.test(normalizedModel.slice(supported.length + 1))) ); } diff --git a/open-sse/config/context1m.ts b/open-sse/config/context1m.ts index dab5c405b1..134df22bf8 100644 --- a/open-sse/config/context1m.ts +++ b/open-sse/config/context1m.ts @@ -34,6 +34,9 @@ export function modelSupportsContext1mBeta(model: string | null | undefined): bo .replace(/-\d{8}$/, ""); return CONTEXT_1M_SUPPORTED_MODELS.some( - (supported) => normalizedModel === supported || normalizedModel.startsWith(`${supported}-`) + (supported) => + normalizedModel === supported || + (normalizedModel.startsWith(`${supported}-`) && + !/^\d/.test(normalizedModel.slice(supported.length + 1))) ); -} \ No newline at end of file +} diff --git a/open-sse/config/embeddingRegistry.ts b/open-sse/config/embeddingRegistry.ts index c4ab2dc2fd..562d2f63c8 100644 --- a/open-sse/config/embeddingRegistry.ts +++ b/open-sse/config/embeddingRegistry.ts @@ -10,6 +10,7 @@ export type EmbeddingModality = "text" | "image" | "audio" | "video" | "document"; export type StructuredEmbeddingProtocol = "jina-v1" | "gemini-embed-content"; +export type SingleTextEmbeddingProtocol = "clova-v2"; export interface EmbeddingModel { id: string; @@ -34,6 +35,13 @@ export interface EmbeddingProvider { models: EmbeddingModel[]; /** Provider-native serializer required for canonical structured input. */ structuredInputProtocol?: StructuredEmbeddingProtocol; + /** + * Set when the endpoint embeds exactly ONE text per request (`{"text": …}` → + * one vector) instead of accepting OpenAI's `input` array. A batched + * `/v1/embeddings` call is then fanned out into N sequential upstream calls and + * merged back into a single OpenAI list response. + */ + singleTextProtocol?: SingleTextEmbeddingProtocol; } export interface EmbeddingProviderNodeRow { @@ -297,6 +305,18 @@ export const EMBEDDING_PROVIDERS: Record = { ], }, + // Naver CLOVA Studio — embedding v2. The endpoint takes a single `{"text": …}` + // body and returns `{status, result:{embedding:[…1024 floats], inputTokens}}`, + // with no batch array and no `usage` object, hence `singleTextProtocol`. + "clova-studio": { + id: "clova-studio", + baseUrl: "https://clovastudio.stream.ntruss.com/v1/api-tools/embedding/v2", + authType: "apikey", + authHeader: "bearer", + singleTextProtocol: "clova-v2", + models: [{ id: "clova-embedding-v2", name: "CLOVA Embedding v2", dimensions: 1024 }], + }, + "jina-ai": { id: "jina-ai", structuredInputProtocol: "jina-v1", @@ -471,6 +491,62 @@ export function getEmbeddingProvider(providerId: string): EmbeddingProvider | nu return EMBEDDING_PROVIDERS[resolveEmbeddingProviderId(providerId)] || null; } +function findDynamicEmbeddingProvider( + modelStr: string, + dynamicProviders: EmbeddingProvider[] | undefined +): { provider: string; model: string } | null { + const match = dynamicProviders?.find((provider) => modelStr.startsWith(`${provider.id}/`)); + return match ? { provider: match.id, model: modelStr.slice(match.id.length + 1) } : null; +} + +function parsePrefixedEmbeddingModel( + modelStr: string, + slashIdx: number, + dynamicProviders: EmbeddingProvider[] | undefined +): { provider: string; model: string } { + const rawProvider = modelStr.slice(0, slashIdx); + const dynamicExact = dynamicProviders?.find((provider) => provider.id === rawProvider); + if (dynamicExact) { + return { provider: rawProvider, model: modelStr.slice(slashIdx + 1) }; + } + + const resolvedProvider = resolveEmbeddingProviderId(rawProvider); + if (EMBEDDING_PROVIDERS[resolvedProvider]) { + return { + provider: resolvedProvider, + model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)), + }; + } + + const hardcodedProvider = Object.keys(EMBEDDING_PROVIDERS).find((providerId) => + modelStr.startsWith(`${providerId}/`) + ); + if (hardcodedProvider) { + return { + provider: hardcodedProvider, + model: normalizeProviderScopedModelId( + hardcodedProvider, + modelStr.slice(hardcodedProvider.length + 1) + ), + }; + } + + return ( + findDynamicEmbeddingProvider(modelStr, dynamicProviders) ?? { + provider: rawProvider, + model: modelStr.slice(slashIdx + 1), + } + ); +} + +function findEmbeddingModelProvider(modelStr: string): string | null { + return ( + Object.entries(EMBEDDING_PROVIDERS).find(([, config]) => + config.models.some((model) => model.id === modelStr) + )?.[0] ?? null + ); +} + /** * Derive an OpenAI-compatible embeddings config for a chat provider that has NO * curated EMBEDDING_PROVIDERS entry. Works for any registry provider whose base @@ -517,59 +593,11 @@ export function parseEmbeddingModel( // Check for "provider/model" format const slashIdx = modelStr.indexOf("/"); if (slashIdx > 0) { - const rawProvider = modelStr.slice(0, slashIdx); - - // A configured provider_node whose prefix exactly equals the requested - // provider segment always wins — even when that segment is also an alias - // of a curated provider (a local node must not be hijacked by a registry - // alias). Same exact-match precedence documented for - // EMBEDDING_MODEL_ALIASES above. - const dynamicExact = - dynamicProviders && dynamicProviders.find((dp) => dp.id === rawProvider); - if (dynamicExact) { - return { provider: rawProvider, model: modelStr.slice(slashIdx + 1) }; - } - - const resolvedProvider = resolveEmbeddingProviderId(rawProvider); - - if (EMBEDDING_PROVIDERS[resolvedProvider]) { - return { - provider: resolvedProvider, - model: normalizeProviderScopedModelId(resolvedProvider, modelStr.slice(slashIdx + 1)), - }; - } - - // Phase 1: Try each hardcoded provider prefix - for (const [providerId] of Object.entries(EMBEDDING_PROVIDERS)) { - if (modelStr.startsWith(providerId + "/")) { - return { - provider: providerId, - model: normalizeProviderScopedModelId(providerId, modelStr.slice(providerId.length + 1)), - }; - } - } - // Phase 2: Try dynamic provider_nodes prefix - if (dynamicProviders) { - for (const dp of dynamicProviders) { - if (modelStr.startsWith(dp.id + "/")) { - return { provider: dp.id, model: modelStr.slice(dp.id.length + 1) }; - } - } - } - // Phase 3: Fallback — first segment is provider - const provider = modelStr.slice(0, slashIdx); - const model = modelStr.slice(slashIdx + 1); - return { provider, model }; + return parsePrefixedEmbeddingModel(modelStr, slashIdx, dynamicProviders); } // No provider prefix — search hardcoded providers for the model - for (const [providerId, config] of Object.entries(EMBEDDING_PROVIDERS)) { - if (config.models.some((m) => m.id === modelStr)) { - return { provider: providerId, model: modelStr }; - } - } - - return { provider: null, model: modelStr }; + return { provider: findEmbeddingModelProvider(modelStr), model: modelStr }; } /** diff --git a/open-sse/config/freeModelCatalog.data.ts b/open-sse/config/freeModelCatalog.data.ts index 64e6a9af7d..dc45a451e9 100644 --- a/open-sse/config/freeModelCatalog.data.ts +++ b/open-sse/config/freeModelCatalog.data.ts @@ -301,18 +301,9 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [ { provider: "nscale", modelId: "openai/gpt-oss-20b", displayName: "openai/gpt-oss-20b", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, { provider: "nscale", modelId: "meta-llama/Llama-4-Scout-17B-16E-Instruct", displayName: "meta-llama/Llama-4-Scout-17B-16E-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, { provider: "nscale", modelId: "meta-llama/Llama-3.3-70B-Instruct", displayName: "meta-llama/Llama-3.3-70B-Instruct", monthlyTokens: 0, creditTokens: 5000000, freeType: "one-time-initial", poolKey: "nscale", tos: "caution" }, - { provider: "nvidia", modelId: "z-ai/glm-5.2", displayName: "GLM 5.2", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "minimaxai/minimax-m2.7", displayName: "MiniMax M2.7", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "google/gemma-4-31b-it", displayName: "Gemma 4 31B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "mistralai/mistral-small-4-119b-2603", displayName: "Mistral Small 4 2603", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "mistralai/mistral-large-3-675b-instruct-2512", displayName: "Mistral Large 3 675B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "mistralai/devstral-2-123b-instruct-2512", displayName: "Devstral 2 123B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "qwen/qwen3.5-397b-a17b", displayName: "Qwen3.5-397B-A17B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "qwen/qwen3.5-122b-a10b", displayName: "Qwen3.5-122B-A10B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "stepfun-ai/step-3.5-flash", displayName: "Step 3.5 Flash", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, - { provider: "nvidia", modelId: "openai/gpt-oss-20b", displayName: "GPT OSS 20B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, { provider: "nvidia", modelId: "nvidia/nemotron-3-super-120b-a12b", displayName: "Nemotron 3 Super 120B A12B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, + { provider: "nvidia", modelId: "openai/gpt-oss-120b", displayName: "GPT OSS 120B", monthlyTokens: 0, creditTokens: 0, freeType: "one-time-initial", poolKey: "nvidia", tos: "caution" }, // evidence: public-page https://ollama.com/pricing (2026-09-02) — Free plan: "Starter usage credits // included · Includes access to starter models · Add credits to unlock all models"; docs.ollama.com/cloud: // "usage resets monthly". No token figure and no named starter-model list ⇒ recurring-uncapped. diff --git a/open-sse/config/nvidiaHostedModels.snapshot.json b/open-sse/config/nvidiaHostedModels.snapshot.json index 60d2f2e76d..fa94a6a4a7 100644 --- a/open-sse/config/nvidiaHostedModels.snapshot.json +++ b/open-sse/config/nvidiaHostedModels.snapshot.json @@ -1,16 +1,14 @@ [ - "google/gemma-4-31b-it", - "minimaxai/minimax-m2.7", - "mistralai/devstral-2-123b-instruct-2512", - "mistralai/mistral-large-3-675b-instruct-2512", - "mistralai/mistral-small-4-119b-2603", - "nvidia/nemotron-3-super-120b-a12b", - "openai/gpt-oss-120b", - "openai/gpt-oss-20b", + "moonshotai/kimi-k3", + "deepseek-ai/deepseek-v4-pro-0813", + "deepseek-ai/deepseek-v4-flash-0731", + "meta/muse-glimmer-30b", "poolside/laguna-xs-2.1", - "qwen/qwen3.5-122b-a10b", - "qwen/qwen3.5-397b-a17b", - "stepfun-ai/step-3.5-flash", - "thinkingmachines/inkling", - "z-ai/glm-5.2" + "google/gemma-4-31b-it", + "google/diffusiongemma-26b-a4b-it", + "nvidia/nemotron-3-ultra-550b-a55b", + "nvidia/nemotron-3-super-120b-a12b", + "nvidia/nemotron-3.5-lightning-30b-a3b", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "openai/gpt-oss-120b" ] diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 1e6b542d13..e6ee28c65f 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -270,6 +270,7 @@ import { voidAiProvider } from "./registry/void-ai/index.ts"; import { helixmindProvider } from "./registry/helixmind/index.ts"; import { tabitokenProvider } from "./registry/tabitoken/index.ts"; import { logfareProvider } from "./registry/logfare/index.ts"; +import { seekaiProvider } from "./registry/seekai/index.ts"; export const REGISTRY: Record = { aimlapi: aimlapiProvider, @@ -544,4 +545,5 @@ export const REGISTRY: Record = { helixmind: helixmindProvider, tabitoken: tabitokenProvider, logfare: logfareProvider, + seekai: seekaiProvider, }; diff --git a/open-sse/config/providers/registry/anthropic/index.ts b/open-sse/config/providers/registry/anthropic/index.ts index 2a3726276d..2845296b15 100644 --- a/open-sse/config/providers/registry/anthropic/index.ts +++ b/open-sse/config/providers/registry/anthropic/index.ts @@ -16,6 +16,17 @@ export const anthropicProvider: RegistryEntry = { "Anthropic-Beta": ANTHROPIC_BETA_API_KEY, }, models: [ + { + id: "claude-fable-5-1", + name: "Claude Fable 5.1", + contextLength: 1000000, + maxOutputTokens: 128000, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "xhigh", "max"], + supportsXHighEffort: true, + supportsVision: true, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-fable-5", name: "Claude Fable 5", diff --git a/open-sse/config/providers/registry/bedrock/index.ts b/open-sse/config/providers/registry/bedrock/index.ts index bb9273fda0..ce70091a27 100644 --- a/open-sse/config/providers/registry/bedrock/index.ts +++ b/open-sse/config/providers/registry/bedrock/index.ts @@ -9,6 +9,17 @@ export const bedrockProvider: RegistryEntry = { authHeader: "bearer", defaultContextLength: 200000, models: [ + { + id: "anthropic.claude-fable-5-1", + name: "Claude Fable 5.1 (Bedrock)", + toolCalling: true, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "xhigh", "max"], + supportsXHighEffort: true, + supportsVision: true, + contextLength: 1000000, + maxOutputTokens: 128000, + }, { id: "anthropic.claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Bedrock)", diff --git a/open-sse/config/providers/registry/claude/index.ts b/open-sse/config/providers/registry/claude/index.ts index 481c831c2f..ba3129678b 100644 --- a/open-sse/config/providers/registry/claude/index.ts +++ b/open-sse/config/providers/registry/claude/index.ts @@ -28,6 +28,17 @@ export const claudeProvider: RegistryEntry = { tokenUrl: "https://api.anthropic.com/v1/oauth/token", }, models: [ + { + id: "claude-fable-5-1", + name: "Claude Fable 5.1", + contextLength: 1000000, + maxOutputTokens: 128000, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "xhigh", "max"], + supportsXHighEffort: true, + supportsVision: true, + unsupportedParams: ["temperature", "top_p", "top_k"], + }, { id: "claude-fable-5", name: "Claude Fable 5", diff --git a/open-sse/config/providers/registry/claude/web/index.ts b/open-sse/config/providers/registry/claude/web/index.ts index c701ff9aa3..07c4872362 100644 --- a/open-sse/config/providers/registry/claude/web/index.ts +++ b/open-sse/config/providers/registry/claude/web/index.ts @@ -9,6 +9,17 @@ export const claude_webProvider: RegistryEntry = { authType: "apikey", authHeader: "cookie", models: [ + { + id: "claude-fable-5-1", + name: "Claude Fable 5.1 (web)", + toolCalling: false, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "medium", "high", "xhigh", "max"], + supportsXHighEffort: true, + supportsVision: true, + contextLength: 1000000, + maxOutputTokens: 128000, + }, { id: "claude-fable-5", name: "Claude Fable 5 (web)", toolCalling: false }, { id: "claude-opus-5", diff --git a/open-sse/config/providers/registry/clova-studio/index.ts b/open-sse/config/providers/registry/clova-studio/index.ts index 8344ebe911..dd371a0336 100644 --- a/open-sse/config/providers/registry/clova-studio/index.ts +++ b/open-sse/config/providers/registry/clova-studio/index.ts @@ -1,17 +1,75 @@ import type { RegistryEntry } from "../../shared.ts"; +/** + * Naver CLOVA Studio — Chat Completions **v3** (native API). + * + * Previously this entry pointed at Naver's OpenAI-compatibility shim + * (`/v1/openai/chat/completions`), which meant `format: "openai"` and a + * pass-through `DefaultExecutor`. The v3 API is Naver's own wire format, so the + * entry now uses `format: "clova"` and the translator pair + * (`openai-to-clova` / `clova-to-openai`). + * + * v3 moves the model into the URL path (`/v3/chat-completions/{modelName}`), uses + * camelCase sampling params, and returns a `{status, result}` envelope instead of + * an OpenAI `choices[]` body — see the translators for the exact mapping. + * + * All three v3 models are live-verified against the real API (2026-09-01): + * + * | Model | Surface | Notes | + * | ------------- | -------- | -------------------------------------------------------- | + * | HCX-007 | thinking | rejects `maxTokens` (use `maxCompletionTokens`); no vision | + * | HCX-005 | text+img | vision via public URL **or** inline base64 data URI | + * | HCX-DASH-002 | text | lightweight, text only | + * + * Docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3 + * https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-thinking + */ export const clova_studioProvider: RegistryEntry = { id: "clova-studio", alias: "clova", - format: "openai", - executor: "default", - baseUrl: "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions", + format: "clova", + executor: "clova-studio", + baseUrl: "https://clovastudio.stream.ntruss.com/v3/chat-completions", authType: "apikey", authHeader: "bearer", + /** + * The v3 API does answer non-streaming requests (`Accept: application/json`), + * but only the streaming surface is expressed in the translator: CLOVA's SSE + * frames carry incremental `token` events plus a terminal `result` event that + * repeats the full text. Forcing the upstream stream lets OmniRoute consume + * that single, well-tested path and accumulate it into a JSON body for + * non-streaming clients, instead of maintaining a second parser for the + * `{status, result}` envelope. + */ + forceStream: true, models: [ - // HCX-007 stays first so it remains the provider default (deep-reasoning - // flagship); HCX-005 is the multimodal option. - { id: "HCX-007", name: "HCX-007" }, - { id: "HCX-005", name: "HCX-005" }, + { + // Reasoning flagship. Input+output ≤ 128000 tokens; the output cap counts + // thinking tokens too, so `maxCompletionTokens` may be up to 32768. + id: "HCX-007", + name: "HCX-007", + contextLength: 128000, + maxOutputTokens: 32768, + supportsReasoning: true, + }, + { + // HyperCLOVA X vision model. Input+output ≤ 128000 tokens, output ≤ 4096, + // up to 5 images per request (1 per turn). Accepts a public URL or an + // inline base64 data URI — the data URI must keep its + // `data:;base64,` prefix inside `dataUri.data` or the request is + // rejected with `40001 Invalid parameter`. + id: "HCX-005", + name: "HCX-005", + contextLength: 128000, + maxOutputTokens: 4096, + supportsVision: true, + }, + { + // Lightweight model. Input+output ≤ 32000 tokens, output ≤ 4096, text only. + id: "HCX-DASH-002", + name: "HCX-DASH-002", + contextLength: 32000, + maxOutputTokens: 4096, + }, ], }; diff --git a/open-sse/config/providers/registry/cursor/index.ts b/open-sse/config/providers/registry/cursor/index.ts index cc5a78f8ce..4b5b3a86de 100644 --- a/open-sse/config/providers/registry/cursor/index.ts +++ b/open-sse/config/providers/registry/cursor/index.ts @@ -1,6 +1,37 @@ -import type { RegistryEntry } from "../../shared.ts"; +import type { RegistryEntry, RegistryModel } from "../../shared.ts"; import { CURSOR_REGISTRY_VERSION, getCursorRegistryHeaders } from "../../shared.ts"; +const CLAUDE_FABLE_5_1_CAPABILITIES = { + maxOutputTokens: 128_000, +} as const; + +const ONE_MILLION_CONTEXT = 1_000_000; + +function withOneMillionContext( + models: RegistryModel[], + familyName: string, + defaultContextLength: number, + liveCatalogId: string, + supportsOneMillion: (model: RegistryModel) => boolean = () => true +): RegistryModel[] { + return models.flatMap((model) => { + const defaultContextModel = { + ...model, + contextLength: defaultContextLength, + liveCatalogIds: model.liveCatalogIds ?? [liveCatalogId], + ...(familyName.startsWith("GPT-") ? {} : { scoresAs: model.scoresAs ?? liveCatalogId }), + }; + if (!supportsOneMillion(model)) return [defaultContextModel]; + const oneMillionModel = { + ...defaultContextModel, + id: `${model.id}-1m`, + name: model.name.replace(familyName, `${familyName} 1M`), + contextLength: ONE_MILLION_CONTEXT, + }; + return [oneMillionModel, defaultContextModel]; + }); +} + export const cursorProvider: RegistryEntry = { id: "cursor", alias: "cu", @@ -18,259 +49,214 @@ export const cursorProvider: RegistryEntry = { { id: "auto-cost", name: "Auto (cost)" }, { id: "auto-balance", name: "Auto (balance)" }, { id: "auto-intelligence", name: "Auto (intelligence)" }, - // Legacy combo ids kept so existing cu/ targets are not orphaned. - { id: "composer-2", name: "Composer 2" }, - { id: "composer-2-fast", name: "Composer 2 Fast" }, - { id: "gpt-5.4-low-fast", name: "GPT 5.4 Low Fast" }, - { id: "gpt-5.3-codex-spark-preview-low", name: "GPT 5.3 Codex Spark Preview Low" }, - { id: "gpt-5.3-codex-spark-preview", name: "GPT 5.3 Codex Spark Preview" }, - { id: "gpt-5.3-codex-spark-preview-high", name: "GPT 5.3 Codex Spark Preview High" }, - { id: "gpt-5.3-codex-spark-preview-xhigh", name: "GPT 5.3 Codex Spark Preview XHigh" }, - // #11489: cursor/agy spell Claude ids - ("claude-4.6-opus-high"); - // the effort splitter strips those to "claude-4.6-opus", which is not a catalog id. - // `scoresAs` points each at the canonical - spelling so quality - // scores are inherited. Operational metadata stays on these entries. - { - id: "claude-4.6-opus-high-thinking-fast", - name: "Claude 4.6 Opus High Thinking Fast", - scoresAs: "claude-opus-4-6", - }, - { - id: "claude-4.6-opus-max-thinking-fast", - name: "Claude 4.6 Opus Max Thinking Fast", - scoresAs: "claude-opus-4-6", - }, - { - id: "claude-4.6-sonnet-medium", - name: "Claude 4.6 Sonnet Medium", - scoresAs: "claude-sonnet-4-6", - }, - { - id: "claude-4.6-sonnet-medium-thinking", - name: "Claude 4.6 Sonnet Medium Thinking", - scoresAs: "claude-sonnet-4-6", - }, - { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, - { id: "gemini-3.7-flash", name: "Gemini 3.7 Flash" }, - { id: "gemini-3-flash", name: "Gemini 3 Flash" }, - { id: "grok-4.6-medium", name: "Grok 4.6 Medium" }, - { id: "grok-4.6-fast-medium", name: "Grok 4.6 Fast Medium" }, - { id: "grok-4.6-high", name: "Grok 4.6 High" }, - { id: "grok-4.6-fast-high", name: "Grok 4.6 Fast High" }, - { id: "grok-4.6-xhigh", name: "Grok 4.6 XHigh" }, - { id: "grok-4.6-fast-xhigh", name: "Grok 4.6 Fast XHigh" }, - { id: "kimi-k3", name: "Kimi K3" }, - { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, - { id: "grok-4.3", name: "Grok 4.3" }, - { id: "grok-4.5-medium", name: "Grok 4.5 Medium" }, - { id: "grok-4.5-fast-medium", name: "Grok 4.5 Fast Medium" }, - { id: "grok-4.5-high", name: "Grok 4.5 High" }, - { id: "grok-4.5-fast-high", name: "Grok 4.5 Fast High" }, - { id: "grok-4.5-xhigh", name: "Grok 4.5 XHigh" }, - { id: "grok-4.5-fast-xhigh", name: "Grok 4.5 Fast XHigh" }, - { id: "kimi-k2.5", name: "Kimi K2.5" }, - { id: "gpt-5.3-codex-low", name: "Codex 5.3 Low" }, - { id: "gpt-5.3-codex-low-fast", name: "Codex 5.3 Low Fast" }, - { id: "gpt-5.3-codex", name: "Codex 5.3" }, - { id: "gpt-5.3-codex-fast", name: "Codex 5.3 Fast" }, - { id: "gpt-5.3-codex-high", name: "Codex 5.3 High" }, - { id: "gpt-5.3-codex-high-fast", name: "Codex 5.3 High Fast" }, - { id: "gpt-5.3-codex-xhigh", name: "Codex 5.3 Extra High" }, - { id: "gpt-5.3-codex-xhigh-fast", name: "Codex 5.3 Extra High Fast" }, - { id: "gpt-5.2", name: "GPT-5.2" }, - { id: "cursor-grok-4.5-high", name: "Cursor Grok 4.5" }, - { id: "cursor-grok-4.5-high-fast", name: "Cursor Grok 4.5 Fast" }, - { id: "composer-2.5", name: "Composer 2.5" }, - { id: "claude-opus-5-thinking-high", name: "Opus 5 1M Thinking" }, - { id: "claude-opus-5-thinking-high-fast", name: "Opus 5 1M Thinking Fast" }, - { id: "claude-opus-5-thinking-xhigh", name: "Opus 5 1M Extra High Thinking" }, - { id: "claude-opus-5-thinking-xhigh-fast", name: "Opus 5 1M Extra High Thinking Fast" }, - { id: "claude-opus-4-8-thinking-high", name: "Opus 4.8 1M Thinking" }, - { id: "claude-opus-4-8-thinking-high-fast", name: "Opus 4.8 1M Thinking Fast" }, - { id: "gpt-5.6-sol-high", name: "GPT-5.6 Sol 1M High" }, - { id: "gpt-5.6-sol-high-fast", name: "GPT-5.6 Sol High Fast" }, - { id: "gpt-5.6-sol-xhigh", name: "GPT-5.6 Sol 1M Extra High" }, - { id: "gpt-5.6-sol-xhigh-fast", name: "GPT-5.6 Sol Extra High Fast" }, - { id: "gpt-5.5-high", name: "GPT-5.5 1M High" }, - { id: "gpt-5.5-high-fast", name: "GPT-5.5 High Fast" }, - { id: "claude-fable-5-thinking-high", name: "Fable 5 1M Thinking (NO ZDR)" }, - { id: "claude-fable-5-thinking-xhigh", name: "Fable 5 1M Extra High Thinking (NO ZDR)" }, - { id: "claude-sonnet-5-thinking-high", name: "Sonnet 5 1M Thinking" }, - { id: "claude-sonnet-5-thinking-xhigh", name: "Sonnet 5 1M Extra High Thinking" }, - { id: "kimi-k3-high", name: "Kimi K3 High" }, - { id: "cursor-grok-4.5-low", name: "Cursor Grok 4.5 Low" }, - { id: "cursor-grok-4.5-low-fast", name: "Cursor Grok 4.5 Low Fast" }, - { id: "cursor-grok-4.5-medium", name: "Cursor Grok 4.5 Medium" }, - { id: "cursor-grok-4.5-medium-fast", name: "Cursor Grok 4.5 Medium Fast" }, + { id: "cursor-grok-4.6-xhigh-fast", name: "Cursor Grok 4.6 Xhigh Fast" }, + { id: "cursor-grok-4.6-xhigh", name: "Cursor Grok 4.6 Xhigh" }, + { id: "cursor-grok-4.6-high-fast", name: "Cursor Grok 4.6 High Fast" }, + { id: "cursor-grok-4.6-high", name: "Cursor Grok 4.6 High" }, + { id: "cursor-grok-4.6-medium-fast", name: "Cursor Grok 4.6 Medium Fast" }, + { id: "cursor-grok-4.6-medium", name: "Cursor Grok 4.6 Medium" }, + { id: "cursor-grok-4.6-low-fast", name: "Cursor Grok 4.6 Low Fast" }, + { id: "cursor-grok-4.6-low", name: "Cursor Grok 4.6 Low" }, { id: "composer-2.5-fast", name: "Composer 2.5 Fast" }, - { id: "claude-opus-5-low", name: "Opus 5 1M Low" }, - { id: "claude-opus-5-low-fast", name: "Opus 5 1M Low Fast" }, - { id: "claude-opus-5-medium", name: "Opus 5 1M Medium" }, - { id: "claude-opus-5-medium-fast", name: "Opus 5 1M Medium Fast" }, - { id: "claude-opus-5-high", name: "Opus 5 1M" }, - { id: "claude-opus-5-high-fast", name: "Opus 5 1M Fast" }, - { id: "claude-opus-5-thinking-low", name: "Opus 5 1M Low Thinking" }, - { id: "claude-opus-5-thinking-low-fast", name: "Opus 5 1M Low Thinking Fast" }, - { id: "claude-opus-5-thinking-medium", name: "Opus 5 1M Medium Thinking" }, - { id: "claude-opus-5-thinking-medium-fast", name: "Opus 5 1M Medium Thinking Fast" }, - { id: "claude-opus-5-thinking-max", name: "Opus 5 1M Max Thinking" }, - { id: "claude-opus-5-thinking-max-fast", name: "Opus 5 1M Max Thinking Fast" }, - { id: "claude-opus-4-8-low", name: "Opus 4.8 1M Low" }, - { id: "claude-opus-4-8-low-fast", name: "Opus 4.8 1M Low Fast" }, - { id: "claude-opus-4-8-medium", name: "Opus 4.8 1M Medium" }, - { id: "claude-opus-4-8-medium-fast", name: "Opus 4.8 1M Medium Fast" }, - { id: "claude-opus-4-8-high", name: "Opus 4.8 1M" }, - { id: "claude-opus-4-8-high-fast", name: "Opus 4.8 1M Fast" }, - { id: "claude-opus-4-8-xhigh", name: "Opus 4.8 1M Extra High" }, - { id: "claude-opus-4-8-xhigh-fast", name: "Opus 4.8 1M Extra High Fast" }, - { id: "claude-opus-4-8-max", name: "Opus 4.8 1M Max" }, - { id: "claude-opus-4-8-max-fast", name: "Opus 4.8 1M Max Fast" }, - { id: "claude-opus-4-8-thinking-low", name: "Opus 4.8 1M Low Thinking" }, - { id: "claude-opus-4-8-thinking-low-fast", name: "Opus 4.8 1M Low Thinking Fast" }, - { id: "claude-opus-4-8-thinking-medium", name: "Opus 4.8 1M Medium Thinking" }, - { id: "claude-opus-4-8-thinking-medium-fast", name: "Opus 4.8 1M Medium Thinking Fast" }, - { id: "claude-opus-4-8-thinking-xhigh", name: "Opus 4.8 1M Extra High Thinking" }, - { id: "claude-opus-4-8-thinking-xhigh-fast", name: "Opus 4.8 1M Extra High Thinking Fast" }, - { id: "claude-opus-4-8-thinking-max", name: "Opus 4.8 1M Max Thinking" }, - { id: "claude-opus-4-8-thinking-max-fast", name: "Opus 4.8 1M Max Thinking Fast" }, - { id: "gpt-5.6-sol-none", name: "GPT-5.6 Sol 1M None" }, - { id: "gpt-5.6-sol-none-fast", name: "GPT-5.6 Sol None Fast" }, - { id: "gpt-5.6-sol-low", name: "GPT-5.6 Sol 1M Low" }, - { id: "gpt-5.6-sol-low-fast", name: "GPT-5.6 Sol Low Fast" }, - { id: "gpt-5.6-sol-medium", name: "GPT-5.6 Sol 1M" }, - { id: "gpt-5.6-sol-medium-fast", name: "GPT-5.6 Sol Fast" }, - { id: "gpt-5.6-sol-max", name: "GPT-5.6 Sol 1M Max" }, - { id: "gpt-5.6-sol-max-fast", name: "GPT-5.6 Sol Max Fast" }, - { id: "gpt-5.5-none", name: "GPT-5.5 1M None" }, - { id: "gpt-5.5-none-fast", name: "GPT-5.5 None Fast" }, - { id: "gpt-5.5-low", name: "GPT-5.5 1M Low" }, - { id: "gpt-5.5-low-fast", name: "GPT-5.5 Low Fast" }, - { id: "gpt-5.5-medium", name: "GPT-5.5 1M" }, - { id: "gpt-5.5-medium-fast", name: "GPT-5.5 Fast" }, - { id: "gpt-5.5-extra-high", name: "GPT-5.5 1M Extra High" }, - { id: "gpt-5.5-extra-high-fast", name: "GPT-5.5 Extra High Fast" }, - { id: "claude-fable-5-low", name: "Fable 5 1M Low (NO ZDR)" }, - { id: "claude-fable-5-medium", name: "Fable 5 1M Medium (NO ZDR)" }, - { id: "claude-fable-5-high", name: "Fable 5 1M (NO ZDR)" }, - { id: "claude-fable-5-xhigh", name: "Fable 5 1M Extra High (NO ZDR)" }, - { id: "claude-fable-5-max", name: "Fable 5 1M Max (NO ZDR)" }, - { id: "claude-fable-5-thinking-low", name: "Fable 5 1M Low Thinking (NO ZDR)" }, - { id: "claude-fable-5-thinking-medium", name: "Fable 5 1M Medium Thinking (NO ZDR)" }, - { id: "claude-fable-5-thinking-max", name: "Fable 5 1M Max Thinking (NO ZDR)" }, - { id: "claude-sonnet-5-low", name: "Sonnet 5 1M Low" }, - { id: "claude-sonnet-5-medium", name: "Sonnet 5 1M Medium" }, - { id: "claude-sonnet-5-high", name: "Sonnet 5 1M" }, - { id: "claude-sonnet-5-xhigh", name: "Sonnet 5 1M Extra High" }, - { id: "claude-sonnet-5-max", name: "Sonnet 5 1M Max" }, - { id: "claude-sonnet-5-thinking-low", name: "Sonnet 5 1M Low Thinking" }, - { id: "claude-sonnet-5-thinking-medium", name: "Sonnet 5 1M Medium Thinking" }, - { id: "claude-sonnet-5-thinking-max", name: "Sonnet 5 1M Max Thinking" }, - { id: "gpt-5.6-terra-none", name: "GPT-5.6 Terra 1M None" }, - { id: "gpt-5.6-terra-none-fast", name: "GPT-5.6 Terra None Fast" }, - { id: "gpt-5.6-terra-low", name: "GPT-5.6 Terra 1M Low" }, - { id: "gpt-5.6-terra-low-fast", name: "GPT-5.6 Terra Low Fast" }, - { id: "gpt-5.6-terra-medium", name: "GPT-5.6 Terra 1M" }, - { id: "gpt-5.6-terra-medium-fast", name: "GPT-5.6 Terra Fast" }, - { id: "gpt-5.6-terra-high", name: "GPT-5.6 Terra 1M High" }, - { id: "gpt-5.6-terra-high-fast", name: "GPT-5.6 Terra High Fast" }, - { id: "gpt-5.6-terra-xhigh", name: "GPT-5.6 Terra 1M Extra High" }, - { id: "gpt-5.6-terra-xhigh-fast", name: "GPT-5.6 Terra Extra High Fast" }, - { id: "gpt-5.6-terra-max", name: "GPT-5.6 Terra 1M Max" }, - { id: "gpt-5.6-terra-max-fast", name: "GPT-5.6 Terra Max Fast" }, - { id: "claude-opus-4-7-low", name: "Opus 4.7 1M Low" }, - { id: "claude-opus-4-7-low-fast", name: "Opus 4.7 1M Low Fast" }, - { id: "claude-opus-4-7-medium", name: "Opus 4.7 1M Medium" }, - { id: "claude-opus-4-7-medium-fast", name: "Opus 4.7 1M Medium Fast" }, - { id: "claude-opus-4-7-high", name: "Opus 4.7 1M High" }, - { id: "claude-opus-4-7-high-fast", name: "Opus 4.7 1M High Fast" }, - { id: "claude-opus-4-7-xhigh", name: "Opus 4.7 1M" }, - { id: "claude-opus-4-7-xhigh-fast", name: "Opus 4.7 1M Fast" }, - { id: "claude-opus-4-7-max", name: "Opus 4.7 1M Max" }, - { id: "claude-opus-4-7-max-fast", name: "Opus 4.7 1M Max Fast" }, - { id: "claude-opus-4-7-thinking-low", name: "Opus 4.7 1M Low Thinking" }, - { id: "claude-opus-4-7-thinking-low-fast", name: "Opus 4.7 1M Low Thinking Fast" }, - { id: "claude-opus-4-7-thinking-medium", name: "Opus 4.7 1M Medium Thinking" }, - { id: "claude-opus-4-7-thinking-medium-fast", name: "Opus 4.7 1M Medium Thinking Fast" }, - { id: "claude-opus-4-7-thinking-high", name: "Opus 4.7 1M High Thinking" }, - { id: "claude-opus-4-7-thinking-high-fast", name: "Opus 4.7 1M High Thinking Fast" }, - { id: "claude-opus-4-7-thinking-xhigh", name: "Opus 4.7 1M Thinking" }, - { id: "claude-opus-4-7-thinking-xhigh-fast", name: "Opus 4.7 1M Thinking Fast" }, - { id: "claude-opus-4-7-thinking-max", name: "Opus 4.7 1M Max Thinking" }, - { id: "claude-opus-4-7-thinking-max-fast", name: "Opus 4.7 1M Max Thinking Fast" }, - { id: "gpt-5.4-low", name: "GPT-5.4 1M Low" }, - { id: "gpt-5.4-medium", name: "GPT-5.4 1M" }, - { id: "gpt-5.4-medium-fast", name: "GPT-5.4 Fast" }, - { id: "gpt-5.4-high", name: "GPT-5.4 1M High" }, - { id: "gpt-5.4-high-fast", name: "GPT-5.4 High Fast" }, - { id: "gpt-5.4-xhigh", name: "GPT-5.4 1M Extra High" }, - { id: "gpt-5.4-xhigh-fast", name: "GPT-5.4 Extra High Fast" }, - // #11489: cursor/agy spell Claude ids - ("claude-4.6-opus-high"); - // the effort splitter strips those to "claude-4.6-opus", which is not a catalog id. - // `scoresAs` points each at the canonical - spelling so quality - // scores are inherited. Operational metadata stays on these entries. - { id: "claude-4.6-opus-high", name: "Opus 4.6 1M", scoresAs: "claude-opus-4-6" }, - { id: "claude-4.6-opus-max", name: "Opus 4.6 1M Max", scoresAs: "claude-opus-4-6" }, - { - id: "claude-4.6-opus-high-thinking", - name: "Opus 4.6 1M Thinking", - scoresAs: "claude-opus-4-6", - }, - { - id: "claude-4.6-opus-max-thinking", - name: "Opus 4.6 1M Max Thinking", - scoresAs: "claude-opus-4-6", - }, - { id: "claude-4.5-opus-high", name: "Opus 4.5", scoresAs: "claude-opus-4-5" }, - { id: "claude-4.5-opus-high-thinking", name: "Opus 4.5 Thinking", scoresAs: "claude-opus-4-5" }, - { id: "gpt-5.2-low", name: "GPT-5.2 Low" }, - { id: "gpt-5.2-low-fast", name: "GPT-5.2 Low Fast" }, - { id: "gpt-5.2-fast", name: "GPT-5.2 Fast" }, - { id: "gpt-5.2-high", name: "GPT-5.2 High" }, - { id: "gpt-5.2-high-fast", name: "GPT-5.2 High Fast" }, - { id: "gpt-5.2-xhigh", name: "GPT-5.2 Extra High" }, - { id: "gpt-5.2-xhigh-fast", name: "GPT-5.2 Extra High Fast" }, - { id: "gpt-5.6-luna-none", name: "GPT-5.6 Luna 1M None" }, - { id: "gpt-5.6-luna-none-fast", name: "GPT-5.6 Luna None Fast" }, - { id: "gpt-5.6-luna-low", name: "GPT-5.6 Luna 1M Low" }, - { id: "gpt-5.6-luna-low-fast", name: "GPT-5.6 Luna Low Fast" }, - { id: "gpt-5.6-luna-medium", name: "GPT-5.6 Luna 1M" }, - { id: "gpt-5.6-luna-medium-fast", name: "GPT-5.6 Luna Fast" }, - { id: "gpt-5.6-luna-high", name: "GPT-5.6 Luna 1M High" }, - { id: "gpt-5.6-luna-high-fast", name: "GPT-5.6 Luna High Fast" }, - { id: "gpt-5.6-luna-xhigh", name: "GPT-5.6 Luna 1M Extra High" }, - { id: "gpt-5.6-luna-xhigh-fast", name: "GPT-5.6 Luna Extra High Fast" }, - { id: "gpt-5.6-luna-max", name: "GPT-5.6 Luna 1M Max" }, - { id: "gpt-5.6-luna-max-fast", name: "GPT-5.6 Luna Max Fast" }, - { id: "gemini-3.6-flash-minimal", name: "Gemini 3.6 Flash Minimal" }, - { id: "gemini-3.6-flash-low", name: "Gemini 3.6 Flash Low" }, - { id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash Medium" }, - { id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash" }, - { id: "gpt-5.4-mini-none", name: "GPT-5.4 Mini None" }, - { id: "gpt-5.4-mini-low", name: "GPT-5.4 Mini Low" }, - { id: "gpt-5.4-mini-medium", name: "GPT-5.4 Mini" }, - { id: "gpt-5.4-mini-high", name: "GPT-5.4 Mini High" }, - { id: "gpt-5.4-mini-xhigh", name: "GPT-5.4 Mini Extra High" }, - { id: "gpt-5.4-nano-none", name: "GPT-5.4 Nano None" }, - { id: "gpt-5.4-nano-low", name: "GPT-5.4 Nano Low" }, - { id: "gpt-5.4-nano-medium", name: "GPT-5.4 Nano" }, - { id: "gpt-5.4-nano-high", name: "GPT-5.4 Nano High" }, - { id: "gpt-5.4-nano-xhigh", name: "GPT-5.4 Nano Extra High" }, - { id: "claude-4.5-sonnet", name: "Sonnet 4.5", scoresAs: "claude-sonnet-4-5" }, - { - id: "claude-4.5-sonnet-thinking", - name: "Sonnet 4.5 Thinking", - scoresAs: "claude-sonnet-4-5", - }, - { id: "gpt-5.1-low", name: "GPT-5.1 Low" }, - { id: "gpt-5.1", name: "GPT-5.1" }, - { id: "gpt-5.1-high", name: "GPT-5.1 High" }, - { id: "claude-4-sonnet", name: "Sonnet 4", scoresAs: "claude-sonnet-4" }, - { id: "claude-4-sonnet-thinking", name: "Sonnet 4 Thinking", scoresAs: "claude-sonnet-4" }, - { id: "gpt-5-mini", name: "GPT-5 Mini" }, + { id: "composer-2.5", name: "Composer 2.5" }, + ...withOneMillionContext( + [ + { + id: "claude-fable-5-1-thinking-max", + name: "Claude Fable 5.1 Max Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + { + id: "claude-fable-5-1-thinking-xhigh", + name: "Claude Fable 5.1 Xhigh Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + { + id: "claude-fable-5-1-thinking-high", + name: "Claude Fable 5.1 High Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + { + id: "claude-fable-5-1-thinking-medium", + name: "Claude Fable 5.1 Medium Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + { + id: "claude-fable-5-1-thinking-low", + name: "Claude Fable 5.1 Low Thinking", + ...CLAUDE_FABLE_5_1_CAPABILITIES, + }, + ], + "Claude Fable 5.1", + 300_000, + "claude-fable-5-1" + ), + ...withOneMillionContext( + [ + { id: "claude-opus-5-thinking-max-fast", name: "Claude Opus 5 Max Thinking Fast" }, + { id: "claude-opus-5-thinking-max", name: "Claude Opus 5 Max Thinking" }, + { + id: "claude-opus-5-thinking-xhigh-fast", + name: "Claude Opus 5 Xhigh Thinking Fast", + }, + { id: "claude-opus-5-thinking-xhigh", name: "Claude Opus 5 Xhigh Thinking" }, + { id: "claude-opus-5-thinking-high-fast", name: "Claude Opus 5 High Thinking Fast" }, + { id: "claude-opus-5-thinking-high", name: "Claude Opus 5 High Thinking" }, + { id: "claude-opus-5-high-fast", name: "Claude Opus 5 High Fast" }, + { id: "claude-opus-5-high", name: "Claude Opus 5 High" }, + { + id: "claude-opus-5-thinking-medium-fast", + name: "Claude Opus 5 Medium Thinking Fast", + }, + { id: "claude-opus-5-thinking-medium", name: "Claude Opus 5 Medium Thinking" }, + { id: "claude-opus-5-medium-fast", name: "Claude Opus 5 Medium Fast" }, + { id: "claude-opus-5-medium", name: "Claude Opus 5 Medium" }, + { id: "claude-opus-5-thinking-low-fast", name: "Claude Opus 5 Low Thinking Fast" }, + { id: "claude-opus-5-thinking-low", name: "Claude Opus 5 Low Thinking" }, + { id: "claude-opus-5-low-fast", name: "Claude Opus 5 Low Fast" }, + { id: "claude-opus-5-low", name: "Claude Opus 5 Low" }, + ], + "Claude Opus 5", + 300_000, + "claude-opus-5" + ), + ...withOneMillionContext( + [ + { id: "claude-opus-4-8-thinking-max-fast", name: "Claude Opus 4.8 Max Thinking Fast" }, + { id: "claude-opus-4-8-thinking-max", name: "Claude Opus 4.8 Max Thinking" }, + { id: "claude-opus-4-8-max-fast", name: "Claude Opus 4.8 Max Fast" }, + { id: "claude-opus-4-8-max", name: "Claude Opus 4.8 Max" }, + { + id: "claude-opus-4-8-thinking-xhigh-fast", + name: "Claude Opus 4.8 Xhigh Thinking Fast", + }, + { id: "claude-opus-4-8-thinking-xhigh", name: "Claude Opus 4.8 Xhigh Thinking" }, + { id: "claude-opus-4-8-xhigh-fast", name: "Claude Opus 4.8 Xhigh Fast" }, + { id: "claude-opus-4-8-xhigh", name: "Claude Opus 4.8 Xhigh" }, + { id: "claude-opus-4-8-thinking-high-fast", name: "Claude Opus 4.8 High Thinking Fast" }, + { id: "claude-opus-4-8-thinking-high", name: "Claude Opus 4.8 High Thinking" }, + { id: "claude-opus-4-8-high-fast", name: "Claude Opus 4.8 High Fast" }, + { id: "claude-opus-4-8-high", name: "Claude Opus 4.8 High" }, + { + id: "claude-opus-4-8-thinking-medium-fast", + name: "Claude Opus 4.8 Medium Thinking Fast", + }, + { id: "claude-opus-4-8-thinking-medium", name: "Claude Opus 4.8 Medium Thinking" }, + { id: "claude-opus-4-8-medium-fast", name: "Claude Opus 4.8 Medium Fast" }, + { id: "claude-opus-4-8-medium", name: "Claude Opus 4.8 Medium" }, + { id: "claude-opus-4-8-thinking-low-fast", name: "Claude Opus 4.8 Low Thinking Fast" }, + { id: "claude-opus-4-8-thinking-low", name: "Claude Opus 4.8 Low Thinking" }, + { id: "claude-opus-4-8-low-fast", name: "Claude Opus 4.8 Low Fast" }, + { id: "claude-opus-4-8-low", name: "Claude Opus 4.8 Low" }, + ], + "Claude Opus 4.8", + 300_000, + "claude-opus-4-8" + ), + ...withOneMillionContext( + [ + { id: "claude-sonnet-5-thinking-max", name: "Claude Sonnet 5 Max Thinking" }, + { id: "claude-sonnet-5-max", name: "Claude Sonnet 5 Max" }, + { id: "claude-sonnet-5-thinking-xhigh", name: "Claude Sonnet 5 Xhigh Thinking" }, + { id: "claude-sonnet-5-xhigh", name: "Claude Sonnet 5 Xhigh" }, + { id: "claude-sonnet-5-thinking-high", name: "Claude Sonnet 5 High Thinking" }, + { id: "claude-sonnet-5-high", name: "Claude Sonnet 5 High" }, + { id: "claude-sonnet-5-thinking-medium", name: "Claude Sonnet 5 Medium Thinking" }, + { id: "claude-sonnet-5-medium", name: "Claude Sonnet 5 Medium" }, + { id: "claude-sonnet-5-thinking-low", name: "Claude Sonnet 5 Low Thinking" }, + { id: "claude-sonnet-5-low", name: "Claude Sonnet 5 Low" }, + ], + "Claude Sonnet 5", + 300_000, + "claude-sonnet-5" + ), + ...withOneMillionContext( + [ + { id: "claude-4.6-sonnet-max-thinking", name: "Claude Sonnet 4.6 Max Thinking" }, + { id: "claude-4.6-sonnet-max", name: "Claude Sonnet 4.6 Max" }, + { id: "claude-4.6-sonnet-high-thinking", name: "Claude Sonnet 4.6 High Thinking" }, + { id: "claude-4.6-sonnet-high", name: "Claude Sonnet 4.6 High" }, + { id: "claude-4.6-sonnet-medium-thinking", name: "Claude Sonnet 4.6 Medium Thinking" }, + { id: "claude-4.6-sonnet-medium", name: "Claude Sonnet 4.6 Medium" }, + { id: "claude-4.6-sonnet-low-thinking", name: "Claude Sonnet 4.6 Low Thinking" }, + { id: "claude-4.6-sonnet-low", name: "Claude Sonnet 4.6 Low" }, + ], + "Claude Sonnet 4.6", + 200_000, + "claude-sonnet-4-6" + ), + { id: "claude-4.5-haiku-thinking", name: "Claude Haiku 4.5 Thinking" }, + { id: "claude-4.5-haiku", name: "Claude Haiku 4.5" }, + ...withOneMillionContext( + [ + { id: "gpt-5.6-sol-max-fast", name: "GPT-5.6 Sol Max Fast" }, + { id: "gpt-5.6-sol-max", name: "GPT-5.6 Sol Max" }, + { id: "gpt-5.6-sol-xhigh-fast", name: "GPT-5.6 Sol Xhigh Fast" }, + { id: "gpt-5.6-sol-xhigh", name: "GPT-5.6 Sol Xhigh" }, + { id: "gpt-5.6-sol-high-fast", name: "GPT-5.6 Sol High Fast" }, + { id: "gpt-5.6-sol-high", name: "GPT-5.6 Sol High" }, + { id: "gpt-5.6-sol-medium-fast", name: "GPT-5.6 Sol Medium Fast" }, + { id: "gpt-5.6-sol-medium", name: "GPT-5.6 Sol Medium" }, + { id: "gpt-5.6-sol-low-fast", name: "GPT-5.6 Sol Low Fast" }, + { id: "gpt-5.6-sol-low", name: "GPT-5.6 Sol Low" }, + { id: "gpt-5.6-sol-none-fast", name: "GPT-5.6 Sol None Fast" }, + { id: "gpt-5.6-sol-none", name: "GPT-5.6 Sol None" }, + ], + "GPT-5.6 Sol", + 272_000, + "gpt-5.6-sol", + (model) => !model.id.endsWith("-fast") + ), + ...withOneMillionContext( + [ + { id: "gpt-5.6-terra-max-fast", name: "GPT-5.6 Terra Max Fast" }, + { id: "gpt-5.6-terra-max", name: "GPT-5.6 Terra Max" }, + { id: "gpt-5.6-terra-xhigh-fast", name: "GPT-5.6 Terra Xhigh Fast" }, + { id: "gpt-5.6-terra-xhigh", name: "GPT-5.6 Terra Xhigh" }, + { id: "gpt-5.6-terra-high-fast", name: "GPT-5.6 Terra High Fast" }, + { id: "gpt-5.6-terra-high", name: "GPT-5.6 Terra High" }, + { id: "gpt-5.6-terra-medium-fast", name: "GPT-5.6 Terra Medium Fast" }, + { id: "gpt-5.6-terra-medium", name: "GPT-5.6 Terra Medium" }, + { id: "gpt-5.6-terra-low-fast", name: "GPT-5.6 Terra Low Fast" }, + { id: "gpt-5.6-terra-low", name: "GPT-5.6 Terra Low" }, + { id: "gpt-5.6-terra-none-fast", name: "GPT-5.6 Terra None Fast" }, + { id: "gpt-5.6-terra-none", name: "GPT-5.6 Terra None" }, + ], + "GPT-5.6 Terra", + 272_000, + "gpt-5.6-terra", + (model) => !model.id.endsWith("-fast") + ), + ...withOneMillionContext( + [ + { id: "gpt-5.6-luna-max-fast", name: "GPT-5.6 Luna Max Fast" }, + { id: "gpt-5.6-luna-max", name: "GPT-5.6 Luna Max" }, + { id: "gpt-5.6-luna-xhigh-fast", name: "GPT-5.6 Luna Xhigh Fast" }, + { id: "gpt-5.6-luna-xhigh", name: "GPT-5.6 Luna Xhigh" }, + { id: "gpt-5.6-luna-high-fast", name: "GPT-5.6 Luna High Fast" }, + { id: "gpt-5.6-luna-high", name: "GPT-5.6 Luna High" }, + { id: "gpt-5.6-luna-medium-fast", name: "GPT-5.6 Luna Medium Fast" }, + { id: "gpt-5.6-luna-medium", name: "GPT-5.6 Luna Medium" }, + { id: "gpt-5.6-luna-low-fast", name: "GPT-5.6 Luna Low Fast" }, + { id: "gpt-5.6-luna-low", name: "GPT-5.6 Luna Low" }, + { id: "gpt-5.6-luna-none-fast", name: "GPT-5.6 Luna None Fast" }, + { id: "gpt-5.6-luna-none", name: "GPT-5.6 Luna None" }, + ], + "GPT-5.6 Luna", + 272_000, + "gpt-5.6-luna", + (model) => !model.id.endsWith("-fast") + ), + { id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash High" }, + { id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash Medium" }, + { id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash Low" }, + { id: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, + { id: "kimi-k3-max", name: "Kimi K3 Max" }, + { id: "kimi-k3-high", name: "Kimi K3 High" }, { id: "kimi-k3-low", name: "Kimi K3 Low" }, - { id: "kimi-k3-max", name: "Kimi K3" }, - { id: "glm-5.2-high", name: "GLM 5.2" }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, { id: "glm-5.2-max", name: "GLM 5.2 Max" }, + { id: "glm-5.2-high", name: "GLM 5.2 High" }, ], }; diff --git a/open-sse/config/providers/registry/devin/catalog.ts b/open-sse/config/providers/registry/devin/catalog.ts index d1e5c75894..6ab53fda12 100644 --- a/open-sse/config/providers/registry/devin/catalog.ts +++ b/open-sse/config/providers/registry/devin/catalog.ts @@ -1,115 +1,150 @@ import type { RegistryModel } from "../../shared.ts"; +type EffortVariant = readonly [suffix: string, label: string]; + +const QUALITY_EFFORTS: readonly EffortVariant[] = [ + ["max", "Max"], + ["xhigh", "XHigh"], + ["high", "High"], + ["medium", "Medium"], + ["low", "Low"], +]; + +const GPT_EFFORTS: readonly EffortVariant[] = [ + ["max", "Max Thinking"], + ["xhigh", "XHigh Thinking"], + ["high", "High Thinking"], + ["medium", "Medium Thinking"], + ["low", "Low Thinking"], + ["none", "No Thinking"], +]; + +function model( + id: string, + name: string, + maxOutputTokens?: number, + contextLength?: number +): RegistryModel { + return { + id, + name, + ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }), + ...(contextLength === undefined ? {} : { contextLength }), + }; +} + +function effortModels( + id: string, + name: string, + maxOutputTokens: number, + contextLength: number | undefined, + efforts: readonly EffortVariant[] = QUALITY_EFFORTS +): RegistryModel[] { + return efforts.map(([suffix, label]) => + model(`${id}-${suffix}`, `${name} ${label}`, maxOutputTokens, contextLength) + ); +} + +function fastEffortModels( + id: string, + name: string, + maxOutputTokens: number, + contextLength: number +): RegistryModel[] { + return QUALITY_EFFORTS.flatMap(([suffix, label]) => [ + model(`${id}-${suffix}-fast`, `${name} ${label} Fast`, maxOutputTokens, contextLength), + model(`${id}-${suffix}`, `${name} ${label}`, maxOutputTokens, contextLength), + ]); +} + +function gptModels(id: string, name: string): RegistryModel[] { + return GPT_EFFORTS.flatMap(([suffix, label]) => [ + model(`${id}-${suffix}-priority`, `${name} ${label} Fast`, 128_000, 1_000_000), + model(`${id}-${suffix}`, `${name} ${label}`, 128_000, 1_000_000), + ]); +} + +/** + * Curated from the authenticated `devin models list --format json` response on + * 2026-09-02. Keep this deliberately smaller than Devin's full live catalog: + * these are the operator-selected models OmniRoute intends to expose. + */ export const DEVIN_MODEL_CATALOG: RegistryModel[] = [ - // Cognition / SWE — default model family recommended for coding tasks - { id: "swe-1-7-lightning", name: "SWE-1.7 Lightning", contextLength: 202752 }, - { id: "swe-1-7", name: "SWE-1.7", contextLength: 262000 }, - { id: "swe-1-6-fast", name: "SWE-1.6 Fast" }, - { id: "swe-1-6", name: "SWE-1.6" }, - // Claude Fable 5 - { id: "claude-5-fable-max", name: "Claude Fable 5 Max", contextLength: 1000000 }, - { id: "claude-5-fable-xhigh", name: "Claude Fable 5 XHigh", contextLength: 1000000 }, - { id: "claude-5-fable-high", name: "Claude Fable 5 High", contextLength: 1000000 }, - { id: "claude-5-fable-medium", name: "Claude Fable 5 Medium", contextLength: 1000000 }, - { id: "claude-5-fable-low", name: "Claude Fable 5 Low", contextLength: 1000000 }, - // Claude Opus 5 - { id: "claude-opus-5-max", name: "Claude Opus 5 Max", contextLength: 1000000 }, - { id: "claude-opus-5-xhigh", name: "Claude Opus 5 XHigh", contextLength: 1000000 }, - { id: "claude-opus-5-high", name: "Claude Opus 5 High", contextLength: 1000000 }, - { id: "claude-opus-5-medium", name: "Claude Opus 5 Medium", contextLength: 1000000 }, - { id: "claude-opus-5-low", name: "Claude Opus 5 Low", contextLength: 1000000 }, - // Claude Opus 4.8 - { id: "claude-opus-4-8-max", name: "Claude Opus 4.8 Max", contextLength: 1000000 }, - { id: "claude-opus-4-8-xhigh", name: "Claude Opus 4.8 XHigh", contextLength: 1000000 }, - { id: "claude-opus-4-8-high", name: "Claude Opus 4.8 High", contextLength: 1000000 }, - { id: "claude-opus-4-8-medium", name: "Claude Opus 4.8 Medium", contextLength: 1000000 }, - { id: "claude-opus-4-8-low", name: "Claude Opus 4.8 Low", contextLength: 1000000 }, - // Claude Opus 4.7 - { id: "claude-opus-4-7-max", name: "Claude Opus 4.7 Max", contextLength: 1000000 }, - { id: "claude-opus-4-7-xhigh", name: "Claude Opus 4.7 XHigh", contextLength: 1000000 }, - { id: "claude-opus-4-7-high", name: "Claude Opus 4.7 High", contextLength: 1000000 }, - { id: "claude-opus-4-7-medium", name: "Claude Opus 4.7 Medium", contextLength: 1000000 }, - { id: "claude-opus-4-7-low", name: "Claude Opus 4.7 Low", contextLength: 1000000 }, - // Claude Opus 4.6 - { - id: "claude-opus-4-6-thinking-1m", - name: "Claude Opus 4.6 Thinking 1M", - contextLength: 1000000, - }, - { id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 Thinking", contextLength: 200000 }, - { id: "claude-opus-4-6-1m", name: "Claude Opus 4.6 1M", contextLength: 1000000 }, - { id: "claude-opus-4-6", name: "Claude Opus 4.6", contextLength: 200000 }, - // Claude Sonnet 5 - { id: "claude-sonnet-5-max", name: "Claude Sonnet 5 Max", contextLength: 1000000 }, - { id: "claude-sonnet-5-xhigh", name: "Claude Sonnet 5 XHigh", contextLength: 1000000 }, - { id: "claude-sonnet-5-high", name: "Claude Sonnet 5 High", contextLength: 1000000 }, - { id: "claude-sonnet-5-medium", name: "Claude Sonnet 5 Medium", contextLength: 1000000 }, - { id: "claude-sonnet-5-low", name: "Claude Sonnet 5 Low", contextLength: 1000000 }, - // Claude Sonnet 4.6 - { - id: "claude-sonnet-4-6-thinking-1m", - name: "Claude Sonnet 4.6 Thinking 1M", - contextLength: 1000000, - }, - { - id: "claude-sonnet-4-6-thinking", - name: "Claude Sonnet 4.6 Thinking", - contextLength: 200000, - }, - { id: "claude-sonnet-4-6-1m", name: "Claude Sonnet 4.6 1M", contextLength: 1000000 }, - { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", contextLength: 200000 }, - // GPT-5.6 - { id: "gpt-5-6-sol-max", name: "GPT-5.6 Sol Max", contextLength: 1000000 }, - { id: "gpt-5-6-sol-xhigh", name: "GPT-5.6 Sol XHigh", contextLength: 1000000 }, - { id: "gpt-5-6-sol-high", name: "GPT-5.6 Sol High", contextLength: 1000000 }, - { id: "gpt-5-6-sol-medium", name: "GPT-5.6 Sol Medium", contextLength: 1000000 }, - { id: "gpt-5-6-sol-low", name: "GPT-5.6 Sol Low", contextLength: 1000000 }, - /// Terra - { id: "gpt-5-6-terra-max", name: "GPT-5.6 Terra Max", contextLength: 1000000 }, - { id: "gpt-5-6-terra-xhigh", name: "GPT-5.6 Terra XHigh", contextLength: 1000000 }, - { id: "gpt-5-6-terra-high", name: "GPT-5.6 Terra High", contextLength: 1000000 }, - { id: "gpt-5-6-terra-medium", name: "GPT-5.6 Terra Medium", contextLength: 1000000 }, - { id: "gpt-5-6-terra-low", name: "GPT-5.6 Terra Low", contextLength: 1000000 }, - /// Luna - { id: "gpt-5-6-luna-max", name: "GPT-5.6 Luna Max", contextLength: 1000000 }, - { id: "gpt-5-6-luna-xhigh", name: "GPT-5.6 Luna XHigh", contextLength: 1000000 }, - { id: "gpt-5-6-luna-high", name: "GPT-5.6 Luna High", contextLength: 1000000 }, - { id: "gpt-5-6-luna-medium", name: "GPT-5.6 Luna Medium", contextLength: 1000000 }, - { id: "gpt-5-6-luna-low", name: "GPT-5.6 Luna Low", contextLength: 1000000 }, - // GPT-5.5 - { id: "gpt-5-5-xhigh", name: "GPT-5.5 XHigh", contextLength: 272000 }, - { id: "gpt-5-5-high", name: "GPT-5.5 High", contextLength: 272000 }, - { id: "gpt-5-5-medium", name: "GPT-5.5 Medium", contextLength: 272000 }, - { id: "gpt-5-5-low", name: "GPT-5.5 Low", contextLength: 272000 }, - // Gemini - { id: "gemini-3-1-pro-high", name: "Gemini 3.1 Pro High", contextLength: 1048576 }, - { id: "gemini-3-1-pro-low", name: "Gemini 3.1 Pro Low", contextLength: 1048576 }, - { id: "gemini-3-7-flash-high", name: "Gemini 3.7 Flash High" }, - { id: "gemini-3-7-flash-medium", name: "Gemini 3.7 Flash Medium" }, - { id: "gemini-3-7-flash-low", name: "Gemini 3.7 Flash Low" }, - { id: "gemini-3-7-flash-minimal", name: "Gemini 3.7 Flash Minimal" }, - // Grok - { id: "grok-4-5-high", name: "Grok 4.5 High", contextLength: 500000 }, - { id: "grok-4-5-medium", name: "Grok 4.5 Medium", contextLength: 500000 }, - { id: "grok-4-5-low", name: "Grok 4.5 Low", contextLength: 500000 }, - // GLM - { id: "glm-5-2-max-1m", name: "GLM-5.2 Max 1M", contextLength: 1000000 }, - { id: "glm-5-2-max", name: "GLM-5.2 Max" }, - { id: "glm-5-2-1m", name: "GLM-5.2 High 1M", contextLength: 1000000 }, - { id: "glm-5-2", name: "GLM-5.2 High" }, - // Kimi - { id: "kimi-k3-max", name: "Kimi K3 Max" }, - { id: "kimi-k3-high", name: "Kimi K3 High" }, - { id: "kimi-k3-low", name: "Kimi K3 Low" }, - { id: "kimi-k2-7", name: "Kimi K2.7", contextLength: 262144 }, - // Inkling - { id: "inkling-max", name: "Inkling Max" }, - { id: "inkling-xhigh", name: "Inkling XHigh" }, - { id: "inkling-high", name: "Inkling High" }, - { id: "inkling-medium", name: "Inkling Medium" }, - { id: "inkling-low", name: "Inkling Low" }, - { id: "inkling-none", name: "Inkling None" }, - // Others - { id: "deepseek-v4", name: "DeepSeek V4 Pro", contextLength: 1048576 }, - { id: "nemotron-3-ultra-nvfp4", name: "Nemotron 3 Ultra", contextLength: 262144 }, + ...effortModels("claude-fable-5-1", "Claude Fable 5.1", 128_000, 1_000_000), + ...fastEffortModels("claude-opus-5", "Claude Opus 5", 128_000, 1_000_000), + ...fastEffortModels("claude-opus-4-8", "Claude Opus 4.8", 128_000, 1_000_000), + ...effortModels("claude-sonnet-5", "Claude Sonnet 5", 128_000, 1_000_000), + + model("claude-sonnet-4-6-thinking-1m", "Claude Sonnet 4.6 Thinking 1M", 128_000, 1_000_000), + model("claude-sonnet-4-6-1m", "Claude Sonnet 4.6 1M", 128_000, 1_000_000), + model("claude-sonnet-4-6-thinking", "Claude Sonnet 4.6 Thinking", 128_000, 200_000), + model("claude-sonnet-4-6", "Claude Sonnet 4.6", 128_000, 200_000), + model("MODEL_PRIVATE_11", "Claude Haiku 4.5", 64_000, 200_000), + + ...gptModels("gpt-5-6-sol", "GPT-5.6 Sol"), + ...gptModels("gpt-5-6-terra", "GPT-5.6 Terra"), + ...gptModels("gpt-5-6-luna", "GPT-5.6 Luna"), + + ...effortModels("kimi-k3", "Kimi K3", 131_072, 1_048_576, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), + model("kimi-k2-7", "Kimi K2.7", 16_000, 262_144), + + ...effortModels("glm-5-3", "GLM-5.3", 128_000, 1_000_000, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), + ...effortModels("glm-5-3-flash", "GLM-5.3 Flash", 128_000, 1_000_000, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), + + model("swe-1-7", "SWE-1.7 Max", 128_000, 262_000), + model("swe-1-7-medium", "SWE-1.7 Medium", 128_000, 262_000), + model("swe-1-7-lightning", "SWE-1.7 Lightning Max", 96_000, 202_752), + model("swe-1-7-lightning-medium", "SWE-1.7 Lightning Medium", 96_000, 202_752), + model("adaptive", "Adaptive"), + + ...effortModels("grok-4-6", "Grok 4.6", 100_000, 500_000, [ + ["xhigh", "XHigh"], + ["high", "High"], + ["medium", "Medium"], + ["low", "Low"], + ]), + ...effortModels("inkling", "Inkling", 131_072, undefined, [ + ["max", "Max"], + ["xhigh", "X-High"], + ["high", "High"], + ["medium", "Medium"], + ["low", "Low"], + ["none", "None"], + ]), + ...effortModels("deepseek-v4-flash", "DeepSeek V4 Flash", 384_000, 1_000_000, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), + ...effortModels("nemotron-3-ultra", "Nemotron 3 Ultra", 32_768, 262_144, [ + ["high", "High"], + ["medium", "Medium"], + ["none", "None"], + ]), + ...effortModels("gemini-3-7-flash", "Gemini 3.7 Flash", 65_535, 1_048_576, [ + ["high", "High"], + ["medium", "Medium"], + ["low", "Low"], + ]), + ...effortModels("gemini-3-1-pro", "Gemini 3.1 Pro", 65_535, 1_048_576, [ + ["high", "High Thinking"], + ["low", "Low Thinking"], + ]), + ...effortModels("deepseek-v4-pro", "DeepSeek V4 Pro", 384_000, 1_000_000, [ + ["max", "Max"], + ["high", "High"], + ["low", "Low"], + ]), ]; diff --git a/open-sse/config/providers/registry/nvidia/index.ts b/open-sse/config/providers/registry/nvidia/index.ts index 0966fa5941..dab45294d3 100644 --- a/open-sse/config/providers/registry/nvidia/index.ts +++ b/open-sse/config/providers/registry/nvidia/index.ts @@ -9,104 +9,34 @@ export const nvidiaProvider: RegistryEntry = { authType: "apikey", authHeader: "bearer", toolNameMaxLength: 64, - // #6773: nvidia multiplexes 17 models from 9 different upstream vendors - // (z-ai/, minimaxai/, deepseek-ai/, qwen/, mistralai/, stepfun-ai/, - // moonshotai/, openai/, nvidia/) behind ONE connection — mark it passthrough + // #6773: NVIDIA multiplexes models from multiple upstream vendors + // (moonshotai/, deepseek-ai/, nvidia/, meta/, poolside/, google/, openai/) + // behind ONE connection — mark it passthrough // so a single stale/renamed model's 404 locks out only that model instead // of cooling down the whole connection (see accountFallback.ts // hasPerModelQuota doc comment; matches modelscope/synthetic/kilo-gateway). passthroughModels: true, models: [ - // #6108: z-ai/glm-5.1 EOL'd 2026-07-02 (direct probe returns 410) — dropped. - // #10788: NVIDIA's hosted GLM-5.2 exposes a BINARY thinking switch - // (chat_template_kwargs.enable_thinking), not effort tiers — see - // mapNvidiaGlm52ReasoningParams. Declaring an empty tier list keeps the - // catalog from synthesizing unresolvable -low/-high/-max variant ids while - // still marking the model reasoning-capable. + { id: "moonshotai/kimi-k3", name: "Kimi K3" }, { - id: "z-ai/glm-5.2", - name: "GLM 5.2", + id: "deepseek-ai/deepseek-v4-pro-0813", + name: "DeepSeek V4 Pro 0813", supportsReasoning: true, - supportedThinkingEfforts: [], }, - // #3329/#6108: minimaxai/minimax-m3 stays excluded from the nvidia tier — it - // still 404s here for most callers; the single 200 probe in #6108 was not - // reproducible enough to override the #3329 guard. Re-add only once NVIDIA - // reliably serves it (and flip nvidia-minimax-m3-removed-3329.test.ts then). - { id: "minimaxai/minimax-m2.7", name: "MiniMax M2.7" }, + { + id: "deepseek-ai/deepseek-v4-flash-0731", + name: "DeepSeek V4 Flash 0731", + supportsReasoning: true, + }, + { id: "meta/muse-glimmer-30b", name: "Muse Glimmer 30B" }, + { id: "poolside/laguna-xs-2.1", name: "Laguna XS 2.1" }, { id: "google/gemma-4-31b-it", name: "Gemma 4 31B" }, - { id: "mistralai/mistral-small-4-119b-2603", name: "Mistral Small 4 2603" }, - { id: "mistralai/mistral-large-3-675b-instruct-2512", name: "Mistral Large 3 675B" }, - { id: "mistralai/devstral-2-123b-instruct-2512", name: "Devstral 2 123B" }, - { id: "qwen/qwen3.5-397b-a17b", name: "Qwen3.5-397B-A17B" }, - { id: "qwen/qwen3.5-122b-a10b", name: "Qwen3.5-122B-A10B" }, - { id: "stepfun-ai/step-3.5-flash", name: "Step 3.5 Flash" }, - { id: "stepfun-ai/step-3.7-flash", name: "Step 3.7 Flash" }, - // Sweep 2026-06-19: verified present in the live NVIDIA NIM /v1/models catalog. - { id: "moonshotai/kimi-k2.6", name: "Kimi K2.6" }, - { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false }, - { id: "openai/gpt-oss-20b", name: "GPT OSS 20B", toolCalling: false }, + { id: "google/diffusiongemma-26b-a4b-it", name: "DiffusionGemma 26B A4B IT" }, + { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra 550B A55B" }, { id: "nvidia/nemotron-3-super-120b-a12b", name: "Nemotron 3 Super 120B A12B" }, - { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra 550B" }, - // Port of decolua/9router#2373 ("fix(nvidia): expand NIM chat model catalog"): - // additional live-catalog models observed to serve /v1/chat/completions. - // `minimaxai/minimax-m3` from that PR is intentionally NOT re-added — it stays - // excluded per the #3329 guard (nvidia-minimax-m3-removed-3329.test.ts). - // Non-chat entries from the same PR (nvidia/gliner-pii — NER tagger, not a chat - // model; google/diffusiongemma-26b-a4b-it — diffusion model) are dropped for the - // same reason: this registry only models the /v1/chat/completions surface. - { id: "abacusai/dracarys-llama-3.1-70b-instruct", name: "Dracarys Llama 3.1 70B Instruct" }, - { id: "google/gemma-2-2b-it", name: "Gemma 2 2B IT" }, - { id: "google/gemma-3n-e2b-it", name: "Gemma 3n E2B IT" }, - { id: "meta/llama-3.1-8b-instruct", name: "Llama 3.1 8B Instruct", toolCalling: false }, { - id: "meta/llama-3.2-11b-vision-instruct", - name: "Llama 3.2 11B Vision Instruct", - supportsVision: true, - }, - { id: "meta/llama-3.2-1b-instruct", name: "Llama 3.2 1B Instruct" }, - { id: "meta/llama-3.2-3b-instruct", name: "Llama 3.2 3B Instruct", toolCalling: false }, - { - id: "meta/llama-3.2-90b-vision-instruct", - name: "Llama 3.2 90B Vision Instruct", - supportsVision: true, - }, - { id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick 17B 128E Instruct" }, - { id: "meta/llama-guard-4-12b", name: "Llama Guard 4 12B", toolCalling: false }, - { id: "mistralai/ministral-14b-instruct-2512", name: "Ministral 14B Instruct 2512" }, - { id: "mistralai/mistral-medium-3.5-128b", name: "Mistral Medium 3.5 128B" }, - { id: "mistralai/mistral-nemotron", name: "Mistral Nemotron" }, - { id: "mistralai/mixtral-8x7b-instruct-v0.1", name: "Mixtral 8x7B Instruct v0.1" }, - { - id: "nvidia/ising-calibration-1-35b-a3b", - name: "Ising Calibration 1 35B A3B", - supportsReasoning: true, - }, - { - id: "nvidia/llama-3.1-nemoguard-8b-content-safety", - name: "Llama 3.1 Nemoguard 8B Content Safety", - }, - { - id: "nvidia/llama-3.1-nemoguard-8b-topic-control", - name: "Llama 3.1 Nemoguard 8B Topic Control", - }, - { id: "nvidia/llama-3.1-nemotron-nano-8b-v1", name: "Llama 3.1 Nemotron Nano 8B v1" }, - { - id: "nvidia/llama-3.1-nemotron-nano-vl-8b-v1", - name: "Llama 3.1 Nemotron Nano VL 8B v1", - supportsVision: true, - }, - { - id: "nvidia/llama-3.1-nemotron-safety-guard-8b-v3", - name: "Llama 3.1 Nemotron Safety Guard 8B v3", - }, - { id: "nvidia/llama-3.3-nemotron-super-49b-v1", name: "Llama 3.3 Nemotron Super 49B v1" }, - { id: "nvidia/llama-3.3-nemotron-super-49b-v1.5", name: "Llama 3.3 Nemotron Super 49B v1.5" }, - { id: "nvidia/nemotron-3-content-safety", name: "Nemotron 3 Content Safety" }, - { - id: "nvidia/nemotron-3-nano-30b-a3b", - name: "Nemotron 3 Nano 30B A3B", - supportsReasoning: true, + id: "nvidia/nemotron-3.5-lightning-30b-a3b", + name: "Nemotron 3.5 Lightning 30B A3B", }, { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", @@ -114,27 +44,6 @@ export const nvidiaProvider: RegistryEntry = { supportsReasoning: true, supportsVision: true, }, - { id: "nvidia/nemotron-3.5-content-safety", name: "Nemotron 3.5 Content Safety" }, - { id: "nvidia/nemotron-mini-4b-instruct", name: "Nemotron Mini 4B Instruct" }, - { - id: "nvidia/nemotron-nano-12b-v2-vl", - name: "Nemotron Nano 12B v2 VL", - supportsReasoning: true, - supportsVision: true, - }, - { - id: "nvidia/nvidia-nemotron-nano-9b-v2", - name: "NVIDIA Nemotron Nano 9B v2", - supportsReasoning: true, - }, - { id: "nvidia/riva-translate-4b-instruct-v1.1", name: "Riva Translate 4B Instruct v1.1" }, - { - id: "qwen/qwen3-next-80b-a3b-instruct", - name: "Qwen3 Next 80B A3B Instruct", - supportsReasoning: true, - }, - { id: "sarvamai/sarvam-m", name: "Sarvam M" }, - { id: "stockmark/stockmark-2-100b-instruct", name: "Stockmark 2 100B Instruct" }, - { id: "upstage/solar-10.7b-instruct", name: "Solar 10.7B Instruct" }, + { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", toolCalling: false }, ], }; diff --git a/open-sse/config/providers/registry/seekai/index.ts b/open-sse/config/providers/registry/seekai/index.ts new file mode 100644 index 0000000000..e37550cc52 --- /dev/null +++ b/open-sse/config/providers/registry/seekai/index.ts @@ -0,0 +1,18 @@ +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +/** + * SeekAi (https://seekai.cc) — QuantumNous New-API gateway. + * Live-verified 2026-09-02: GET /api/status → system_name=SeekAi, + * version=v1.0.0-rc.25, quota_display_type=USD. GET /v1/models is + * API-key gated (401 Invalid token without a key). Catalog is dynamic; + * no static seed. Referral/aff query params stay out of this entry + * (no-hardcoded-referral-codes). + */ +export const seekaiProvider = buildOpenAiCompatibleRegistryEntry({ + id: "seekai", + alias: "ska", + baseUrl: "https://seekai.cc/v1/chat/completions", + modelsUrl: "https://seekai.cc/v1/models", + models: [], + passthroughModels: true, +}); diff --git a/open-sse/config/providers/registry/vertex/index.ts b/open-sse/config/providers/registry/vertex/index.ts index 1eac20fc15..4b72b9f7c4 100644 --- a/open-sse/config/providers/registry/vertex/index.ts +++ b/open-sse/config/providers/registry/vertex/index.ts @@ -27,6 +27,7 @@ export const vertexProvider: RegistryEntry = { { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro (Vertex Partner)" }, { id: "Qwen3.6-35B-A3B", name: "Qwen3.6 35B A3B (Vertex Partner)" }, { id: "GLM-5.1-FP8", name: "GLM-5.1 (Vertex Partner)" }, + { id: "claude-fable-5-1", name: "Claude Fable 5.1 (Vertex)", targetFormat: "claude" }, { id: "claude-fable-5", name: "Claude Fable 5 (Vertex)", targetFormat: "claude" }, { id: "claude-opus-5", name: "Claude Opus 5 (Vertex)", targetFormat: "claude" }, { id: "claude-sonnet-5", name: "Claude Sonnet 5 (Vertex)", targetFormat: "claude" }, diff --git a/open-sse/config/providers/registry/vertex/partner/index.ts b/open-sse/config/providers/registry/vertex/partner/index.ts index 4cdcd5d0b5..6524cd6a2b 100644 --- a/open-sse/config/providers/registry/vertex/partner/index.ts +++ b/open-sse/config/providers/registry/vertex/partner/index.ts @@ -13,6 +13,7 @@ export const vertex_partnerProvider: RegistryEntry = { { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" }, { id: "Qwen3.6-35B-A3B", name: "Qwen 3.6 35B A3B" }, { id: "GLM-5.1-FP8", name: "GLM 5.1" }, + { id: "claude-fable-5-1", name: "Claude Fable 5.1", targetFormat: "claude" }, { id: "claude-fable-5", name: "Claude Fable 5", targetFormat: "claude" }, { id: "claude-opus-5", name: "Claude Opus 5", targetFormat: "claude" }, { id: "claude-sonnet-5", name: "Claude Sonnet 5", targetFormat: "claude" }, diff --git a/open-sse/config/providers/registry/zai-web/index.ts b/open-sse/config/providers/registry/zai-web/index.ts index 98901daab9..59b2b61c31 100644 --- a/open-sse/config/providers/registry/zai-web/index.ts +++ b/open-sse/config/providers/registry/zai-web/index.ts @@ -14,30 +14,27 @@ export const zai_webProvider: RegistryEntry = { // Z.ai's visible "Tools" switch enables its internal VLM/MCP tools. It does // not accept caller-supplied OpenAI `tools`, which remains disabled here. models: [ + { + id: "glm-5.3-flash", + name: "GLM-5.3-Flash", + toolCalling: false, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "high", "max"], + supportsVision: true, + }, + { + id: "glm-5.3", + name: "GLM-5.3", + toolCalling: false, + supportsReasoning: true, + supportedThinkingEfforts: ["low", "high", "max"], + }, { id: "glm-5.2", name: "GLM-5.2", toolCalling: false, supportsReasoning: true, - }, - { - id: "GLM-5.1", - name: "GLM-5.1", - toolCalling: false, - supportsReasoning: true, - }, - { - id: "GLM-5-Turbo", - name: "GLM-5-Turbo", - toolCalling: false, - supportsReasoning: true, - }, - { - id: "GLM-5v-Turbo", - name: "GLM-5V-Turbo", - toolCalling: false, - supportsReasoning: true, - supportsVision: true, + supportedThinkingEfforts: ["high", "max"], }, ], }; diff --git a/open-sse/executors/adapta-web.ts b/open-sse/executors/adapta-web.ts index 8c9ae571ea..da1b836002 100644 --- a/open-sse/executors/adapta-web.ts +++ b/open-sse/executors/adapta-web.ts @@ -33,6 +33,15 @@ interface CachedSession { jwtExpiresAt: number; // unix ms } +const SESSION_CACHE_MAX = 100; + +function evictOldest(cache: Map): void { + if (cache.size >= SESSION_CACHE_MAX) { + const first = cache.keys().next().value; + if (first) cache.delete(first); + } +} + // Keyed by the first 32 chars of the stored __client JWT const sessionCache = new Map(); @@ -44,11 +53,15 @@ function cachedJwt(clientJwt: string): string | null { const entry = sessionCache.get(cacheKey(clientJwt)); if (!entry) return null; // Keep a 30-second buffer before expiry - if (Date.now() >= entry.jwtExpiresAt - 30_000) return null; + if (Date.now() >= entry.jwtExpiresAt - 30_000) { + sessionCache.delete(cacheKey(clientJwt)); + return null; + } return entry.jwt; } function storeSession(clientJwt: string, sessionId: string, jwt: string, expMs: number): void { + evictOldest(sessionCache); sessionCache.set(cacheKey(clientJwt), { sessionId, jwt, jwtExpiresAt: expMs }); } diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index c16d626812..9582af9fe6 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -26,6 +26,7 @@ import { } from "../services/antigravityCredits.ts"; import { persistCreditBalance, getAllPersistedCreditBalances } from "@/lib/db/creditBalance"; import { setConnectionRateLimitUntil } from "@/lib/db/providers"; +import { markAntigravityModelQuotaExhausted } from "../services/antigravityFamilyCooldown.ts"; import { getMitmAlias } from "@/lib/db/models"; import { MAX_ANTIGRAVITY_OUTPUT_TOKENS, @@ -245,17 +246,15 @@ export function createCreditsExtractionTransform( ); } -/** - * Persist a quota-exhausted cooldown to the DB for `connectionId` so that - * cross-request and post-restart routing skips this connection until the - * cooldown expires. Exported for unit testing. @internal - */ -export function markConnectionQuotaExhausted(connectionId: string, retryAfterMs: number): void { +export function markConnectionQuotaExhausted( + connectionId: string, + retryAfterMs: number, + model?: string | null +): void { try { + if (markAntigravityModelQuotaExhausted(connectionId, retryAfterMs, model)) return; setConnectionRateLimitUntil(connectionId, Date.now() + retryAfterMs); - } catch { - // DB write failure must never crash the request path - } + } catch {} } /** @@ -1620,7 +1619,7 @@ export class AntigravityExecutor extends BaseExecutor { updateAntigravityRemainingCredits ); if (creditsResult) return { kind: "return", result: creditsResult }; - if (retryMs) markConnectionQuotaExhausted(accountId, retryMs); + if (retryMs) markConnectionQuotaExhausted(accountId, retryMs, ctx.model); } return { diff --git a/open-sse/executors/antigravity/sseCollect.ts b/open-sse/executors/antigravity/sseCollect.ts index 630b091aab..5b7ef3ea85 100644 --- a/open-sse/executors/antigravity/sseCollect.ts +++ b/open-sse/executors/antigravity/sseCollect.ts @@ -16,6 +16,11 @@ export type AntigravityCollectedStream = { remainingCredits: Array<{ creditType: string; creditAmount: string }> | null; }; +// Both run once per SSE data line / per text part (processAntigravitySSEPayload), +// so the literals are hoisted to module constants. +const TEXTUAL_TOOL_CALL_RE = + /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/; + export function stripZeroWidth(value: unknown): unknown { if (typeof value === "string") { return stripObfuscationZeroWidth(value); @@ -39,9 +44,7 @@ export function parseAntigravityTextualToolCall( ): { name: string; args: unknown } | null { if (typeof text !== "string") return null; const normalized = stripObfuscationZeroWidth(text); - const match = normalized.match( - /^[\s\S]*?\[Tool call:\s*([^\]\n]+)\]\s*\nArguments:\s*([\s\S]+?)\s*$/ - ); + const match = normalized.match(TEXTUAL_TOOL_CALL_RE); if (!match) return null; const name = match[1]?.trim(); const rawArgs = match[2]?.trim(); diff --git a/open-sse/executors/cloudflare-playground.ts b/open-sse/executors/cloudflare-playground.ts index ba309f1eed..249e069da3 100644 --- a/open-sse/executors/cloudflare-playground.ts +++ b/open-sse/executors/cloudflare-playground.ts @@ -36,6 +36,7 @@ import { randomUUID } from "crypto"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; import { makeExecutorErrorResult as makeErrorResult } from "../utils/error.ts"; +import { connectObscuraBrowser } from "../services/obscura.ts"; import type { Browser, Page } from "playwright"; export const PLAYGROUND_URL = "https://playground.ai.cloudflare.com/"; @@ -296,14 +297,22 @@ export class PlaywrightCfTransport implements CfTransport { config: CfTransportConfig ): Promise<{ ok: true } | { ok: false; status: number; message: string }> { try { + // #12274: prefer the shared Obscura browser (browser-grade TLS fingerprint + // on the WS upgrade, ~30MB) over a full Chromium per request; fall back to + // a direct Chromium launch when Obscura is unavailable. + const obscura = await connectObscuraBrowser(); 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, - }); + if (obscura) { + this.browser = obscura.browser; + } else { + 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; diff --git a/open-sse/executors/clova-studio.ts b/open-sse/executors/clova-studio.ts new file mode 100644 index 0000000000..28997a78b9 --- /dev/null +++ b/open-sse/executors/clova-studio.ts @@ -0,0 +1,12 @@ +import { DefaultExecutor } from "./default.ts"; + +/** CLOVA Chat Completions v3 places the selected model in the URL path. */ +export class ClovaStudioExecutor extends DefaultExecutor { + constructor() { + super("clova-studio"); + } + + buildUrl(model: string): string { + return `${this.config.baseUrl}/${encodeURIComponent(model)}`; + } +} diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 62e3a1da13..4701f175c2 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -538,6 +538,10 @@ export function codexDropNonstandardEvents(): boolean { // every `codex.*` event block from the byte stream before it reaches the client. // Exported for unit testing (#4715). Strips `codex.*` SSE event blocks from a // streaming Response when `codexDropNonstandardEvents()` is on (default, #11014). +// Pre-compiled: the filter's transform() runs on every chunk, so these were +// re-allocated per block/iteration before hoisting. +const CODEX_SSE_EVENT_LINE_RE = /^event:\s*(.+)$/m; +const CODEX_SSE_BLOCK_SEP_RE = /\r?\n\r?\n/; export function filterNonstandardCodexSse(response: Response): Response { const contentType = response.headers.get("content-type") || ""; if (!response.body || !contentType.includes("text/event-stream")) { @@ -547,14 +551,14 @@ export function filterNonstandardCodexSse(response: Response): Response { const encoder = new TextEncoder(); let buffer = ""; const dropBlock = (block: string): boolean => { - const match = /^event:\s*(.+)$/m.exec(block); + const match = CODEX_SSE_EVENT_LINE_RE.exec(block); return !!match && match[1].trim().startsWith("codex."); }; const transform = new TransformStream({ transform(chunk, controller) { buffer += decoder.decode(chunk, { stream: true }); while (true) { - const separator = /\r?\n\r?\n/.exec(buffer); + const separator = CODEX_SSE_BLOCK_SEP_RE.exec(buffer); if (!separator) break; const blockEnd = separator.index + separator[0].length; const block = buffer.slice(0, blockEnd); diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 7f1b850b23..ef9f370669 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -223,6 +223,8 @@ export function translateSseResponse( suppressThinkClose: boolean = false ): Response { if (!response.body) return response; + // GLM is a high-throughput provider — use a larger stream buffer (64KB) to + // keep provider → client pacing ahead of the model's token emission rate. const transform = createSSETransformStreamWithLogger( FORMATS.CLAUDE, FORMATS.OPENAI, @@ -236,7 +238,10 @@ export function translateSseResponse( null, null, false, - suppressThinkClose + suppressThinkClose, + undefined, + undefined, + 65536 ); const headers = cloneHeaders(response.headers); headers.set("content-type", "text/event-stream"); diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index d1c3b9ced0..fed0a651c1 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -180,6 +180,7 @@ const lazyExecutors: Record Promise> = { xai: () => import("./xai.ts").then((m) => new m.XaiExecutor()), "xai-oauth": () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")), xao: () => import("./xai.ts").then((m) => new m.XaiExecutor("xai-oauth")), + "clova-studio": () => import("./clova-studio.ts").then((m) => new m.ClovaStudioExecutor()), "conol-web": () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()), cnl: () => import("./conol-web.ts").then((m) => new m.ConolWebExecutor()), // Alias }; diff --git a/open-sse/executors/tinycms.ts b/open-sse/executors/tinycms.ts index 68c916e047..c100f76e38 100644 --- a/open-sse/executors/tinycms.ts +++ b/open-sse/executors/tinycms.ts @@ -16,7 +16,9 @@ async function getPublicIp(): Promise { return publicIp; } try { - const res = await fetch("https://api64.ipify.org?format=json"); + const res = await fetch("https://api64.ipify.org?format=json", { + signal: AbortSignal.timeout(5000), + }); const json = (await res.json()) as { ip: string }; publicIp = json.ip; lastIpFetch = now; @@ -35,6 +37,7 @@ async function fetchChallenge(uuid: string): Promise { Accept: "application/json", "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", }, + signal: AbortSignal.timeout(10000), }); if (!res.ok) { throw new Error(`Failed to fetch challenge: ${res.status}`); diff --git a/open-sse/executors/zai-web.ts b/open-sse/executors/zai-web.ts index 6e01aee98d..fb09e4ea82 100644 --- a/open-sse/executors/zai-web.ts +++ b/open-sse/executors/zai-web.ts @@ -5,8 +5,9 @@ * browser-issued CAPTCHA proof for chat completions. The browser transport is * the default; callers with a short-lived proof can use the direct HTTP path. * - * Completions go to /api/v2/chat/completions; the older unversioned - * /api/chat/completions path is stale and 404s model-independently (#8014). + * Completions go to /api/v2/chat/completions. Z.ai's CAPTCHA rejects true + * headless Chromium with F001, so the browser transport uses off-screen headed + * Chromium while retaining the shared browser pool. */ import { createHash, randomUUID } from "node:crypto"; import { BaseExecutor, type ExecuteInput } from "./base.ts"; @@ -67,6 +68,7 @@ export { parseZaiFrontendVersion, resolveZaiThinkingConfig, resolveZaiVlmConfig, + zaiUpstreamModelId, } from "./zai-web/protocol.ts"; export type { ZaiModelCapabilities, @@ -174,6 +176,7 @@ function buildZaiBrowserChatOptions(input: { userAgent: ZAI_USER_AGENT, locale: "en-US", timezone: "Asia/Seoul", + headless: false, inputSelector: "#chat-input", submitButtonSelector: '[aria-label="Send Message"] button:not([disabled])', submitButtonMode: "dom", @@ -243,7 +246,7 @@ function resolveZaiRequest( const modelId = (bodyObj.model as string) || model || ZAI_DEFAULT_MODEL; if (imageUrls.length > 0 && !getZaiModelCapabilities(modelId).vision) { return fail( - `Z.ai model ${unprefixedModelId(modelId)} does not accept image input; use GLM-5V-Turbo.` + `Z.ai model ${unprefixedModelId(modelId)} does not accept image input; use GLM-5.3-Flash.` ); } diff --git a/open-sse/executors/zai-web/browserAutomation.ts b/open-sse/executors/zai-web/browserAutomation.ts index 38924ada4d..a8aabccc33 100644 --- a/open-sse/executors/zai-web/browserAutomation.ts +++ b/open-sse/executors/zai-web/browserAutomation.ts @@ -23,14 +23,17 @@ async function runStage(name: string, action: () => Promise): Promise { const selector = page.locator('[aria-label="Select a model"]').first(); await selector.waitFor({ state: "visible", timeout: 10_000 }); - if ((await selector.innerText()).includes(modelName)) return; + if ((await selector.getByText(modelName, { exact: true }).count()) > 0) return; // The landing-page hero animation can remain above the already-visible // selector and make coordinate-based clicks time out. await selector.evaluate((element) => (element as HTMLElement).click()); const menu = page.locator('[role="menu"]').filter({ hasText: modelName }).first(); await menu.waitFor({ state: "visible", timeout: 5_000 }); - const modelButton = menu.locator("button").filter({ hasText: modelName }).first(); + const modelButton = menu + .getByText(modelName, { exact: true }) + .first() + .locator("xpath=ancestor::button[1]"); await modelButton.evaluate((element) => (element as HTMLElement).click()); await page .locator('[aria-label="Select a model"]') @@ -73,13 +76,13 @@ async function setZaiBrowserWebSearch(page: Page, enabled: boolean): Promise, effort: ZaiThinkingConfig["effort"] ): Promise { const effortButton = menu.locator("button").filter({ - hasText: effort === "high" ? "High" : "Max", + hasText: effort === "low" ? "Low" : effort === "high" ? "High" : "Max", }); if ((await effortButton.getAttribute("data-selected")) === "true") return; await runStage(`select ${effort}`, () => diff --git a/open-sse/executors/zai-web/protocol.ts b/open-sse/executors/zai-web/protocol.ts index 562ca85a7f..511e5d3aa9 100644 --- a/open-sse/executors/zai-web/protocol.ts +++ b/open-sse/executors/zai-web/protocol.ts @@ -7,8 +7,8 @@ import { normalizeCookie, sanitizeErrorMessage } from "../../utils/error.ts"; export const ZAI_BASE_URL = "https://chat.z.ai"; export const ZAI_NEW_CHAT_URL = `${ZAI_BASE_URL}/api/v1/chats/new`; export const ZAI_CHAT_URL = `${ZAI_BASE_URL}/api/v2/chat/completions`; -export const ZAI_DEFAULT_MODEL = "GLM-5.1"; -export const ZAI_DEFAULT_FE_VERSION = "prod-fe-1.1.79"; +export const ZAI_DEFAULT_MODEL = "glm-5.3"; +export const ZAI_DEFAULT_FE_VERSION = "prod-fe-1.1.92"; export const ZAI_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"; export const ZAI_FE_VERSION_CACHE_TTL_MS = 15 * 60 * 1000; @@ -21,7 +21,7 @@ export interface NewChatRequest { userMessageId: string; } -export type ZaiReasoningEffort = "high" | "max"; +export type ZaiReasoningEffort = "low" | "high" | "max"; export interface ZaiThinkingConfig { enabled: boolean; @@ -61,12 +61,23 @@ const NO_ZAI_MODEL_CAPABILITIES: ZaiModelCapabilities = Object.freeze({ }); /** - * Verified against chat.z.ai/api/models (prod-fe-1.1.79). + * Verified against chat.z.ai/api/models (prod-fe-1.1.92). * `returnFc` is the site's internal function-call result capability; it is * distinct from accepting caller-supplied OpenAI `tools`. */ const ZAI_MODEL_CAPABILITIES: Record = { - "glm-5.2": { + "glm-5.3-flash": { + mcp: false, + reasoningEffort: true, + returnFc: true, + thinking: true, + vision: true, + vlmTools: false, + vlmWebSearch: false, + vlmWebsiteMode: false, + webSearch: true, + }, + "glm-5.3": { mcp: true, reasoningEffort: true, returnFc: true, @@ -77,9 +88,9 @@ const ZAI_MODEL_CAPABILITIES: Record = { vlmWebsiteMode: false, webSearch: true, }, - "glm-5.1": { + "glm-5.2": { mcp: true, - reasoningEffort: false, + reasoningEffort: true, returnFc: true, thinking: true, vision: false, @@ -88,28 +99,6 @@ const ZAI_MODEL_CAPABILITIES: Record = { vlmWebsiteMode: false, webSearch: true, }, - "glm-5-turbo": { - mcp: true, - reasoningEffort: false, - returnFc: true, - thinking: true, - vision: false, - vlmTools: false, - vlmWebSearch: false, - vlmWebsiteMode: false, - webSearch: true, - }, - "glm-5v-turbo": { - mcp: false, - reasoningEffort: false, - returnFc: true, - thinking: true, - vision: true, - vlmTools: true, - vlmWebSearch: true, - vlmWebsiteMode: true, - webSearch: true, - }, }; export function asRecord(value: unknown): Record | null { @@ -136,6 +125,7 @@ export function describeZaiBrowserFailure(result: { status: number; body: Buffer; observedPostUrls?: string[]; + observedPostResponses?: Array<{ url: string; status: number }>; timing: { captureResponseMs: number; totalMs: number }; }): string { const status = result.status > 0 ? String(result.status) : "no matching response"; @@ -144,10 +134,16 @@ export function describeZaiBrowserFailure(result: { result.observedPostUrls && result.observedPostUrls.length > 0 ? ` Observed POST targets: ${result.observedPostUrls.join(", ")}.` : ""; + const observedResponses = + result.observedPostResponses && result.observedPostResponses.length > 0 + ? ` Observed POST responses: ${result.observedPostResponses + .map(({ url, status }) => `${url} [${status}]`) + .join(", ")}.` + : ""; const detail = browserFailureDetail(result.body) || (result.status === 0 - ? `The page did not issue the expected authenticated chat completion request.${observed}` + ? `The page did not issue the expected authenticated chat completion request.${observed}${observedResponses}` : "The browser response body was empty."); return `Z.ai browser transport failed (${status}; ${timing}): ${detail}`; } @@ -307,17 +303,27 @@ export function unprefixedModelId(modelId: string): string { return modelId.trim().split("/").at(-1) || modelId.trim(); } -export function browserModelName(modelId: string): string { +/** Map OmniRoute's public Flash id to the opaque id used by chat.z.ai's wire API. */ +export function zaiUpstreamModelId(modelId: string): string { const unprefixed = unprefixedModelId(modelId); - if (unprefixed.toLowerCase() === "glm-5.2") return "GLM-5.2"; - if (unprefixed.toLowerCase() === "glm-5v-turbo") return "GLM-5V-Turbo"; - return unprefixed; + return unprefixed.toLowerCase() === "glm-5.3-flash" ? "x-preview-l" : unprefixed; +} + +function zaiCapabilityModelId(modelId: string): string { + const unprefixed = unprefixedModelId(modelId).toLowerCase(); + return unprefixed === "x-preview-l" ? "glm-5.3-flash" : unprefixed; +} + +export function browserModelName(modelId: string): string { + const normalized = zaiCapabilityModelId(modelId); + if (normalized === "glm-5.3-flash") return "GLM-5.3-Flash"; + if (normalized === "glm-5.3") return "GLM-5.3"; + if (normalized === "glm-5.2") return "GLM-5.2"; + return unprefixedModelId(modelId); } export function getZaiModelCapabilities(modelId: string): ZaiModelCapabilities { - return ( - ZAI_MODEL_CAPABILITIES[unprefixedModelId(modelId).toLowerCase()] ?? NO_ZAI_MODEL_CAPABILITIES - ); + return ZAI_MODEL_CAPABILITIES[zaiCapabilityModelId(modelId)] ?? NO_ZAI_MODEL_CAPABILITIES; } function getFeatureOption(body: Record, key: string): unknown { @@ -325,7 +331,7 @@ function getFeatureOption(body: Record, key: string): unknown { return asRecord(body.features)?.[key]; } -/** Resolve each model's Deep Think control; only GLM-5.2 accepts High/Max effort. */ +/** Resolve each model's Deep Think control using its currently exposed effort vocabulary. */ export function resolveZaiThinkingConfig( modelId: string, body: Record @@ -339,19 +345,26 @@ export function resolveZaiThinkingConfig( : typeof reasoning?.effort === "string" ? reasoning.effort.trim().toLowerCase() : ""; - const disabled = body.enable_thinking === false || rawEffort === "none" || rawEffort === "off"; + const supportsLowEffort = zaiCapabilityModelId(modelId) !== "glm-5.2"; const effort: ZaiReasoningEffort = - rawEffort === "low" || rawEffort === "medium" || rawEffort === "high" ? "high" : "max"; + rawEffort === "low" && supportsLowEffort + ? "low" + : rawEffort === "low" || rawEffort === "medium" || rawEffort === "high" + ? "high" + : "max"; return { supported, - enabled: supported && !disabled, + // The current GLM-5.3/5.2 consumer models expose effort selection but no + // non-thinking mode. Keep Deep Think enabled even when a generic client + // sends an off/none compatibility value. + enabled: supported, effort, effortSupported: capabilities.reasoningEffort, }; } -/** Resolve GLM-5V-Turbo's visible Web Search and Tools controls. */ +/** Resolve the selected model's visible Web Search and Tools controls. */ export function resolveZaiVlmConfig(modelId: string, body: Record): ZaiVlmConfig { const capabilities = getZaiModelCapabilities(modelId); const toolsOption = getFeatureOption(body, "vlm_tools_enable"); @@ -425,7 +438,7 @@ export function buildZaiCompletionUrl(input: { hostname: "chat.z.ai", protocol: "https:", referrer: "", - title: "Z.ai - Advanced AI Chatbot & Agent powered by GLM-5.2", + title: "Z.ai - Advanced AI Chatbot & Agent powered by GLM-5.3", timezone_offset: "0", local_time: now.toISOString(), utc_time: now.toUTCString(), @@ -448,13 +461,14 @@ export function buildZaiNewChatBody( ): NewChatRequest { const prompt = latestUserPrompt(messages); const userMessageId = randomUUID(); + const upstreamModelId = zaiUpstreamModelId(modelId); return { userMessageId, payload: { chat: { id: "", title: "New Chat", - models: [modelId], + models: [upstreamModelId], params: {}, history: { messages: { @@ -465,7 +479,7 @@ export function buildZaiNewChatBody( role: "user", content: prompt, timestamp: Math.floor(Date.now() / 1000), - models: [modelId], + models: [upstreamModelId], }, }, currentId: userMessageId, @@ -530,7 +544,7 @@ export function buildZaiRequestBody(input: { } return { stream: true, - model: input.modelId, + model: zaiUpstreamModelId(input.modelId), messages: foldMessages(input.messages), signature_prompt: input.prompt, params, diff --git a/open-sse/executors/zcodeProtocol.ts b/open-sse/executors/zcodeProtocol.ts index 12a5cd1a0e..b787b73d4f 100644 --- a/open-sse/executors/zcodeProtocol.ts +++ b/open-sse/executors/zcodeProtocol.ts @@ -178,7 +178,7 @@ export class ZcodeAppServerClient implements ZcodeClientLike { private readonly startupTimeoutMs: number; private readonly requestTimeoutMs: number; private child?: ChildProcessWithoutNullStreams; - private outputBuffer = Buffer.alloc(0); + private pendingChunks: Buffer[] = []; private handshakeDone = false; private ready = false; private startPromise?: Promise; @@ -220,7 +220,7 @@ export class ZcodeAppServerClient implements ZcodeClientLike { } this.child = child; - this.outputBuffer = Buffer.alloc(0); + this.pendingChunks = []; this.handshakeDone = false; this.ready = false; child.stdin.on("error", () => { @@ -271,18 +271,29 @@ export class ZcodeAppServerClient implements ZcodeClientLike { } } + // Buffer accumulated stdout bytes. Chunks are collected in an array and + // collapsed into one contiguous buffer only when a complete frame (or the + // hello line) might be present — the previous `concat(prev, chunk)` per data + // event re-allocated the whole buffer on every chunk, i.e. O(n²) total. private onStdout(chunk: Buffer): void { - this.outputBuffer = Buffer.concat([this.outputBuffer, chunk]); + this.pendingChunks.push(chunk); + let total = 0; + for (const part of this.pendingChunks) total += part.byteLength; + const buffer = total === chunk.byteLength && this.pendingChunks.length > 0 + ? chunk + : Buffer.concat(this.pendingChunks); + this.pendingChunks = [buffer]; + if (!this.handshakeDone) { - const newline = this.outputBuffer.indexOf(0x0a); + const newline = buffer.indexOf(0x0a); if (newline < 0) { - if (this.outputBuffer.byteLength > 64 * 1024) { + if (buffer.byteLength > 64 * 1024) { this.serverReadyError?.(new Error("ZCode hello line is too large")); } return; } - const line = this.outputBuffer.subarray(0, newline).toString("utf8").trim(); - this.outputBuffer = this.outputBuffer.subarray(newline + 1); + const line = buffer.subarray(0, newline).toString("utf8").trim(); + this.pendingChunks = [buffer.subarray(newline + 1)]; let hello: unknown; try { hello = JSON.parse(line); @@ -307,9 +318,13 @@ export class ZcodeAppServerClient implements ZcodeClientLike { } private consumeFrames(): void { - while (this.outputBuffer.byteLength >= HEADER_SIZE) { - const type = this.outputBuffer.readUInt8(0); - const length = this.outputBuffer.readUInt32BE(9); + // Collapse to one buffer for frame scanning (only happens once per data + // event since onStdout already deduped), then drop consumed frames. + const buffer = this.pendingChunks[0]; + let offset = 0; + while (buffer.byteLength - offset >= HEADER_SIZE) { + const type = buffer.readUInt8(offset); + const length = buffer.readUInt32BE(offset + 9); if (length > MAX_FRAME_BYTES) { const error = new Error("ZCode frame exceeds the configured safety limit"); this.serverReadyError?.(error); @@ -317,9 +332,9 @@ export class ZcodeAppServerClient implements ZcodeClientLike { return; } const frameLength = HEADER_SIZE + length; - if (this.outputBuffer.byteLength < frameLength) return; - const body = this.outputBuffer.subarray(HEADER_SIZE, frameLength); - this.outputBuffer = this.outputBuffer.subarray(frameLength); + if (buffer.byteLength - offset < frameLength) break; + const body = buffer.subarray(offset + HEADER_SIZE, offset + frameLength); + offset += frameLength; if (type !== REGULAR_MESSAGE) continue; try { const header = decodeZcodeValue(body, 0); @@ -331,6 +346,7 @@ export class ZcodeAppServerClient implements ZcodeClientLike { this.rejectPending(normalized); } } + if (offset > 0) this.pendingChunks = [buffer.subarray(offset)]; } private handleMessage(headerValue: unknown, payload: unknown): void { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8dbac0018b..65a986d186 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -360,6 +360,10 @@ import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { guardrailRegistry } from "@/lib/guardrails"; import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge"; +import { + logClientRawRequestRedacted, + redactPendingBody, +} from "@/lib/guardrails/videoBridgeSnapshotRedaction"; import { shouldPreserveCacheControl, resolveConnectionCacheOverride, @@ -380,6 +384,7 @@ import { sanitizeOpenAITool } from "../services/toolSchemaSanitizer.ts"; import { isCompactResponsesEndpoint } from "../executors/codex.ts"; import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts"; import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts"; +import { invalidateGenericQuotaCacheOnStatus } from "../services/genericQuotaFetcher.ts"; import { translateNonStreamingResponse } from "./responseTranslator.ts"; import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts"; import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts"; @@ -927,7 +932,7 @@ export async function handleChatCore({ const pendingRequestId = trackPendingRequest(model, provider, pendingConnId, true, { clientEndpoint: clientRawRequest?.endpoint || "/v1/chat/completions", - clientRequest: clientRawRequest?.body ?? body, + clientRequest: redactPendingBody(clientRawRequest?.body ?? body, videoBridgeObserved), providerRequest: initialProviderRequest, stage: "registered", correlationId, @@ -1211,14 +1216,9 @@ export async function handleChatCore({ }); const pendingScope = { id: pendingRequestId, model, provider, connectionId: pendingConnId }; const providerRequestCapture = createPreparedRequestLogger(reqLogger, pendingScope); - // 0. Log client raw request (before format conversion) - if (clientRawRequest) { - reqLogger.logClientRawRequest( - clientRawRequest.endpoint, - clientRawRequest.body, - clientRawRequest.headers - ); - } + // 0. Log client raw request (before format conversion) — redacts video transcript + // cues in the logged copy only; see videoBridgeSnapshotRedaction.ts. + logClientRawRequestRedacted(reqLogger, clientRawRequest, videoBridgeObserved); const reasoningRouteDecision = body && typeof body === "object" ? (body as Record)._omnirouteReasoningRouteTrace @@ -3240,6 +3240,14 @@ export async function handleChatCore({ const errMessage = err instanceof Error ? err.message : String(err); log?.debug?.("CODEX", `Failed to persist codex quota state: ${errMessage}`); } + } else if (attemptConnectionId && res.response.status === 429) { + // Dropped generic quota cache after 429 + invalidateGenericQuotaCacheOnStatus({ + provider, + connectionId: String(attemptConnectionId), + status: res.response.status, + isolateProbe: await shouldIsolateProbeFailures(), + }); } // Track Gemini RPM + RPD request counts for 429 classification diff --git a/open-sse/handlers/chatCore/attemptLogging.ts b/open-sse/handlers/chatCore/attemptLogging.ts index 2a583896a7..07de9f19b2 100644 --- a/open-sse/handlers/chatCore/attemptLogging.ts +++ b/open-sse/handlers/chatCore/attemptLogging.ts @@ -13,6 +13,7 @@ import { extractProviderWarnings } from "@/lib/compliance/providerAudit"; import { logAuditEvent } from "@/lib/compliance"; import { emit } from "@/lib/events/eventBus"; +import { maybeLogToolCallSpecViolation } from "./toolCallSpecViolationAudit.ts"; import type { RequestCompletedPayload, RequestFailedPayload } from "@/lib/events/types"; import { saveCallLog } from "@/lib/usageDb"; import type { VideoBridgeLogRedactionEntry } from "@/lib/guardrails/videoBridge"; @@ -50,6 +51,16 @@ import { attachLogMeta } from "./cacheUsageMeta.ts"; * never touches a part whose text differs — see * `tests/unit/video-bridge-log-redaction.test.ts`'s "Scenario A" test for the * reproduction this fixes. + * + * #12430 item 4 (P2c): a message's `content` can also be a plain STRING that + * embeds `fullText` as a SUBSTRING rather than an exact array part — derived + * dispatches (pipeline-strategy stages, smart-auto-pipeline, context-handoff + * summaries) all interpolate the transcript blob into a larger rendered + * prompt string before calling `handleSingleModel`. That string branch is + * mutually exclusive with the array branch (a message's `content` is one or + * the other, never both) and uses `String.prototype.replaceAll` against the + * trusted `fullText` literal to swap every occurrence — see + * `tests/unit/video-bridge-derived-prompt-redaction.test.ts`. */ export function applyVideoBridgeLogRedaction( body: unknown, @@ -78,6 +89,47 @@ export function applyVideoBridgeLogRedaction( const originalMessage = originalContainer[messageIndex]; if (!originalMessage || typeof originalMessage !== "object") continue; const originalContent = (originalMessage as Record).content; + + // Derived-prompt dispatches (pipeline-strategy stages, smart-auto-pipeline, + // context-handoff summaries — #12430 item 4) embed the transcript as a + // SUBSTRING of a plain string `content`, e.g. a rendered stage prompt or a + // `{HISTORY}`-interpolated handoff summary, never as an exact array part. + // Mutually exclusive with the array branch below: a message's `content` + // is either a string or an array, never both, so this and the + // `Array.isArray` check never both match the same message. + if (typeof originalContent === "string") { + if (!originalContent.includes(fullText)) continue; + + // Same lazy clone-on-write as the array branch: root -> container + // array -> this message. Siblings keep referencing the originals. + if (!rootClone) rootClone = { ...source }; + let containerClone = clonedContainers.get(container); + if (!containerClone) { + containerClone = [...originalContainer]; + clonedContainers.set(container, containerClone); + rootClone[container] = containerClone; + } + + const messageKey = `${container}:${messageIndex}`; + let messageClone = clonedMessages.get(messageKey); + if (!messageClone) { + messageClone = { ...(originalMessage as Record) }; + clonedMessages.set(messageKey, messageClone); + containerClone[messageIndex] = messageClone; + } + + // Re-read from the (possibly already-cloned) message so a second + // redaction entry matching the same string content composes with the + // first instead of clobbering it. `fullText` is a trusted literal + // (the `[Video description:...]` blob), so replaceAll(string, string) + // needs no regex and is safe. replaceAll (not replace): a stage/summary + // prompt can quote the transcript back more than once. + const currentText = + typeof messageClone.content === "string" ? messageClone.content : originalContent; + messageClone.content = currentText.replaceAll(fullText, redactedText); + redacted = true; + continue; + } if (!Array.isArray(originalContent)) continue; for (let partIndex = 0; partIndex < originalContent.length; partIndex++) { @@ -341,6 +393,15 @@ export function persistAttemptLogs(args: PersistAttemptLogsArgs, ctx: PersistAtt }); } + maybeLogToolCallSpecViolation({ + responseBody, + provider, + model, + connectionId: finalConnectionId, + httpStatus: status, + requestId: skillRequestId, + }); + const capturedPipeline = reqLogger?.getPipelinePayloads?.() ?? null; const pipelinePayloads = detailedLoggingEnabled ? (capturedPipeline ?? {}) diff --git a/open-sse/handlers/chatCore/toolCallSpecViolationAudit.ts b/open-sse/handlers/chatCore/toolCallSpecViolationAudit.ts new file mode 100644 index 0000000000..573daae59e --- /dev/null +++ b/open-sse/handlers/chatCore/toolCallSpecViolationAudit.ts @@ -0,0 +1,46 @@ +/** + * Post-request on-spec audit for duplicated tool_calls. + * + * Extracted from persistAttemptLogs so attemptLogging.ts stays at the frozen + * complexity count. validateResponseQuality's streaming peek only sees the + * START of a stream, so a duplicate that arrives after real content has + * already been relayed cannot fail the attempt over — this is the first + * point the fully assembled body is available. Too late to retry; a durable + * audit row still beats a clean HTTP 200 with no trace. + * + * Observed: minimax-m3:free via OpenRouter/GMICloud, 2026-09-02, duplicated + * a heartbeat_respond call byte-for-byte. + */ + +import { logAuditEvent } from "@/lib/compliance"; +import { findToolCallSpecViolation } from "../../services/combo/validateQuality.ts"; + +export function maybeLogToolCallSpecViolation(input: { + responseBody: unknown; + provider: string | null | undefined; + model: string | null | undefined; + connectionId: string | null; + httpStatus: number; + requestId: string; +}): void { + const violation = findToolCallSpecViolation(input.responseBody); + if (!violation) return; + logAuditEvent({ + action: "provider.spec_violation", + actor: "system", + target: + [input.provider, input.connectionId].filter(Boolean).join(":") || + input.provider || + input.model, + resourceType: "provider_spec_violation", + status: "warning", + requestId: input.requestId, + details: { + provider: input.provider, + model: input.model, + connectionId: input.connectionId, + httpStatus: input.httpStatus, + violation, + }, + }); +} diff --git a/open-sse/handlers/embeddingStructuredInput.ts b/open-sse/handlers/embeddingStructuredInput.ts index 1183c9e5a3..1cd7571a38 100644 --- a/open-sse/handlers/embeddingStructuredInput.ts +++ b/open-sse/handlers/embeddingStructuredInput.ts @@ -128,10 +128,7 @@ export async function prepareJinaMixedEmbeddingInput( continue; } if (isCanonicalEmbeddingItem(item)) { - const [translated] = await prepareJinaInput( - [item as EmbeddingMultimodalItem], - fetchMedia - ); + const [translated] = await prepareJinaInput([item as EmbeddingMultimodalItem], fetchMedia); out.push(translated); continue; } @@ -163,7 +160,9 @@ function embeddingValues(entry: unknown): unknown[] { return Array.isArray(values) ? values : []; } -function normalizeGeminiEmbedContentResponse(data: Record): Record { +function normalizeGeminiEmbedContentResponse( + data: Record +): Record { return { object: "list", data: [{ object: "embedding", embedding: embeddingValues(data.embedding), index: 0 }], @@ -263,10 +262,7 @@ async function itemToGeminiContent( return { parts: [await jinaDocToGeminiPart(item, fetchMedia)] }; } if (isCanonicalEmbeddingItem(item)) { - const [part] = await prepareGeminiParts( - [item as EmbeddingMultimodalItem], - fetchMedia - ); + const [part] = await prepareGeminiParts([item as EmbeddingMultimodalItem], fetchMedia); return { parts: [part] }; } throw new Error("Unsupported Gemini embedding input item"); @@ -346,3 +342,41 @@ export async function prepareStructuredEmbeddingRequest( } throw new Error(`Provider ${provider.id} has no structured embedding input translator`); } + +/** + * Normalize a single-text embedding endpoint's response into OpenAI's + * `/v1/embeddings` list shape. + * + * CLOVA Studio's embedding v2 answers: + * + * ``` + * {"status":{"code":"20000","message":"OK"}, + * "result":{"embedding":[…1024 floats],"inputTokens":4}} + * ``` + * + * There is no `data[]` and no `usage` object, so both are synthesized. `index` is + * left at 0 here — the batching loop in `embeddings.ts` rewrites it to the + * caller's position before the response is returned. + * + * A non-20000 status or malformed success envelope throws so an HTTP-200 error + * envelope can never be exposed as an empty successful embedding response. + */ +export function normalizeClovaEmbeddingV2Response( + rawData: Record +): Record { + const statusCode = (rawData?.status as { code?: unknown } | undefined)?.code; + if (String(statusCode) !== "20000") { + throw new Error("CLOVA Studio embedding v2 returned an unsuccessful status"); + } + + const result = (rawData?.result ?? {}) as Record; + if (!Array.isArray(result.embedding)) { + throw new Error("CLOVA Studio embedding v2 response is missing an embedding vector"); + } + + const inputTokens = Number(result.inputTokens) || 0; + return { + data: [{ object: "embedding", index: 0, embedding: result.embedding }], + usage: { prompt_tokens: inputTokens, total_tokens: inputTokens }, + }; +} diff --git a/open-sse/handlers/embeddings.ts b/open-sse/handlers/embeddings.ts index 7cf322610d..c60842b09f 100644 --- a/open-sse/handlers/embeddings.ts +++ b/open-sse/handlers/embeddings.ts @@ -1,16 +1,8 @@ /** * Embedding Handler * - * Handles POST /v1/embeddings requests. - * Proxies to upstream embedding providers using OpenAI-compatible format. - * - * Request format (OpenAI-compatible): - * { - * "model": "nebius/Qwen/Qwen3-Embedding-8B", - * "input": "text" | ["text1", "text2"], - * "dimensions": 4096, // optional - * "encoding_format": "float" // optional - * } + * Handles POST /v1/embeddings requests and normalizes provider responses to the + * OpenAI embedding shape. */ import { @@ -32,6 +24,7 @@ import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; import { fetchRemoteImage } from "@/shared/network/remoteImageFetch"; import { hasStructuredEmbeddingInput, + normalizeClovaEmbeddingV2Response, prepareJinaMixedEmbeddingInput, prepareStructuredEmbeddingRequest, } from "./embeddingStructuredInput.ts"; @@ -53,17 +46,82 @@ interface ClientRawRequest { headers: Record; } -/** - * Flatten a single embedding item's vector to the OpenAI-spec `number[]` shape. - * - * Some OpenAI-compatible embedding backends — notably a llama.cpp - * `llama-server --embedding --pooling ...` instance — return each vector wrapped in one - * extra array level: `[[...floats]]` instead of `[...floats]` for a single input. That - * extra level is silently spec-breaking, since a standard OpenAI-SDK consumer reading - * `response.data[i].embedding` gets a length-1 array holding the real vector instead of - * the vector itself. Unwrap only that single redundant level; vectors that are already - * flat (or genuinely multi-row) are left untouched. See issue #9089. - */ +interface EmbeddingCredentials { + apiKey?: string | null; + accessToken?: string | null; + providerSpecificData?: Record | null; +} + +interface EmbeddingLog { + info: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +interface HandleEmbeddingParams { + body: Record; + credentials: EmbeddingCredentials | null; + log?: EmbeddingLog; + resolvedProvider?: EmbeddingProvider | null; + resolvedModel?: string | null; + clientRawRequest?: ClientRawRequest | null; + apiKeyId?: string | null; + apiKeyName?: string | null; + connectionId?: string | null; +} + +interface EmbeddingFailure { + success: false; + status: number; + error: string; + headers?: Headers; + data?: never; +} + +interface EmbeddingSuccess { + success: true; + data: Record; + headers: Headers; + status?: never; + error?: never; +} + +type EmbeddingResult = EmbeddingSuccess | EmbeddingFailure; + +interface ResolvedEmbedding { + provider: string | null; + model: string | null; + providerConfig: EmbeddingProvider | null; +} + +type RequestLogger = Awaited>; +type ProviderResponseNormalizer = + ((data: Record) => Record) | null; + +interface EmbeddingRuntime extends HandleEmbeddingParams { + provider: string; + model: string | null; + providerConfig: EmbeddingProvider; + startTime: number; + detailedLoggingEnabled: boolean; + reqLogger: RequestLogger; + logRequestBody: Record; +} + +interface PreparedEmbeddingRequest { + upstreamBody: Record; + upstreamUrl: string; + headers: Record; + normalizeProviderResponse: ProviderResponseNormalizer; +} + +interface ParsedEmbeddingResponse { + data?: unknown[] | unknown; + usage?: { prompt_tokens?: number; total_tokens?: number }; +} + +const KNOWN_EMBEDDING_FIELDS = new Set(["model", "input", "dimensions", "encoding_format"]); + +/** Unwrap one redundant row around an otherwise flat vector. */ function flattenSingleRowEmbedding(item: unknown): void { if (!item || typeof item !== "object" || !("embedding" in item)) return; const record = item as { embedding: unknown }; @@ -78,103 +136,81 @@ function flattenSingleRowEmbedding(item: unknown): void { } } -/** - * Handle embedding request. - * Supports both hardcoded cloud providers and dynamic local provider_nodes. - * When resolvedProvider is passed, uses it directly (injection pattern from route handler). - * Falls back to hardcoded registry lookup for backward compatibility. - */ -export async function handleEmbedding({ - body, - credentials, - log, - resolvedProvider = null, - resolvedModel = null, - clientRawRequest = null, - apiKeyId = null, - apiKeyName = null, - connectionId = null, -}: { - body: Record; - credentials: { - apiKey?: string | null; - accessToken?: string | null; - providerSpecificData?: Record | null; - } | null; - log?: { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; - resolvedProvider?: EmbeddingProvider | null; - resolvedModel?: string | null; - clientRawRequest?: ClientRawRequest | null; - apiKeyId?: string | null; - apiKeyName?: string | null; - connectionId?: string | null; -}) { - // Use pre-resolved provider/model from route handler if available (supports dynamic provider_nodes). - let provider: string | null; - let model: string | null; - let providerConfig: EmbeddingProvider | null; +function failure(status: number, error: string, headers?: Headers): EmbeddingFailure { + return { success: false, status, error, ...(headers ? { headers } : {}) }; +} - if (resolvedProvider) { - provider = resolvedProvider.id; - model = resolvedModel; - providerConfig = resolvedProvider; - } else { - const parsed = parseEmbeddingModel(body.model as string); - provider = parsed.provider; - model = parsed.model; - providerConfig = provider ? getEmbeddingProvider(provider) : null; +function resolveEmbedding(params: HandleEmbeddingParams): ResolvedEmbedding { + if (params.resolvedProvider) { + return { + provider: params.resolvedProvider.id, + model: params.resolvedModel ?? null, + providerConfig: params.resolvedProvider, + }; } + const parsed = parseEmbeddingModel(params.body.model as string); + return { + provider: parsed.provider, + model: parsed.model, + providerConfig: parsed.provider ? getEmbeddingProvider(parsed.provider) : null, + }; +} - const startTime = Date.now(); - - // Set up request logger for pipeline artifact capture +async function createEmbeddingRuntime( + params: HandleEmbeddingParams, + resolved: ResolvedEmbedding +): Promise { const detailedLoggingEnabled = await isDetailedLoggingEnabled(); - const captureStreamChunks = getCallLogPipelineCaptureStreamChunks(); const reqLogger = await createRequestLogger( - provider || "openai", + resolved.provider || "openai", "openai", - body.model as string, + params.body.model as string, { enabled: detailedLoggingEnabled, - captureStreamChunks, - connectionId: connectionId || undefined, - model: model || (body.model as string), - provider: provider || undefined, + captureStreamChunks: getCallLogPipelineCaptureStreamChunks(), + connectionId: params.connectionId || undefined, + model: resolved.model || (params.body.model as string), + provider: resolved.provider || undefined, } ); - // Log client raw request - if (clientRawRequest) { + if (params.clientRawRequest) { reqLogger.logClientRawRequest( - clientRawRequest.endpoint, - clientRawRequest.body, - clientRawRequest.headers + params.clientRawRequest.endpoint, + params.clientRawRequest.body, + params.clientRawRequest.headers ); } + if (!resolved.provider) { + return failure( + 400, + `Invalid embedding model: ${params.body.model}. Use format: provider/model` + ); + } + if (!resolved.providerConfig) { + return failure(400, `Unknown embedding provider: ${resolved.provider}`); + } - // Summarized request body for call log (avoid storing large embedding input arrays) - const logRequestBody = { - model: body.model, - input_count: Array.isArray(body.input) ? body.input.length : 1, - dimensions: body.dimensions || undefined, + return { + ...params, + provider: resolved.provider, + model: resolved.model, + providerConfig: resolved.providerConfig, + startTime: Date.now(), + detailedLoggingEnabled, + reqLogger, + logRequestBody: { + model: params.body.model, + input_count: Array.isArray(params.body.input) ? params.body.input.length : 1, + dimensions: params.body.dimensions || undefined, + }, }; +} - if (!provider) { - return { - success: false, - status: 400, - error: `Invalid embedding model: ${body.model}. Use format: provider/model`, - }; - } - - if (!providerConfig) { - return { - success: false, - status: 400, - error: `Unknown embedding provider: ${provider}`, - }; - } - +function collectRequestedModalities(body: Record): { + structuredItems: Array<{ type: EmbeddingModality }>; + nativeModalities: EmbeddingModality[]; +} { const structuredItems = Array.isArray(body.input) ? body.input.filter( (item): item is { type: EmbeddingModality } => @@ -184,409 +220,493 @@ export async function handleEmbedding({ 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 { - success: false, - status: 400, - error: `Embedding model ${body.model} does not advertise structured embedding input support`, - }; - } - const unsupportedCanonical = structuredItems.find( - (item) => !supportedModalities.includes(item.type) + ].filter((modality): modality is EmbeddingModality => modality !== "text"); + return { structuredItems, nativeModalities }; +} + +function validateRequestedModalities(runtime: EmbeddingRuntime): EmbeddingFailure | null { + const { structuredItems, nativeModalities } = collectRequestedModalities(runtime.body); + if (structuredItems.length === 0 && nativeModalities.length === 0) return null; + + const supported = getEmbeddingModelModalities(runtime.providerConfig, runtime.model); + if (!supported) { + return failure( + 400, + `Embedding model ${runtime.body.model} does not advertise structured embedding input support` ); - if (unsupportedCanonical) { - return { - success: false, - status: 400, - 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`, - }; - } } + const unsupportedCanonical = structuredItems.find((item) => !supported.includes(item.type)); + if (unsupportedCanonical) { + return failure( + 400, + `Embedding model ${runtime.body.model} does not support ${unsupportedCanonical.type} input` + ); + } + const unsupportedNative = nativeModalities.find((modality) => !supported.includes(modality)); + return unsupportedNative + ? failure( + 400, + `Embedding model ${runtime.body.model} does not support ${unsupportedNative} input` + ) + : null; +} - // Build upstream request — start with standard fields, then forward extra fields - // the client sent (e.g. input_type, user, truncate for NVIDIA NIM asymmetric models). - const KNOWN_FIELDS = new Set(["model", "input", "dimensions", "encoding_format"]); - - let upstreamBody: Record = { - model: model, - input: body.input, +function buildUpstreamBody(runtime: EmbeddingRuntime): Record { + const upstreamBody: Record = { + model: runtime.model, + input: runtime.body.input, }; - - if (body.dimensions !== undefined) upstreamBody.dimensions = body.dimensions; - if (body.encoding_format !== undefined) upstreamBody.encoding_format = body.encoding_format; - - for (const [key, value] of Object.entries(body)) { - if (!KNOWN_FIELDS.has(key) && value !== undefined) { - upstreamBody[key] = value; - } + if (runtime.body.dimensions !== undefined) upstreamBody.dimensions = runtime.body.dimensions; + if (runtime.body.encoding_format !== undefined) { + upstreamBody.encoding_format = runtime.body.encoding_format; + } + for (const [key, value] of Object.entries(runtime.body)) { + if (!KNOWN_EMBEDDING_FIELDS.has(key) && value !== undefined) upstreamBody[key] = value; } - // Gemini embedding models (gemini-embedding-001 / -2-preview / text-embedding-004) - // default to 3072-dim vectors. Clients targeting pgvector-style schemas typically - // request a smaller size (e.g. 1536) via OpenAI's `dimensions` field, but Google's - // OpenAI-compatibility shim at /v1beta/openai/embeddings does not document the - // `dimensions` → `outputDimensionality` translation. Mirror the request value into - // the Gemini-native `outputDimensionality` field so the upstream actually returns - // the requested vector size. Ported from upstream decolua/9router#1366. - if (provider === "gemini" && upstreamBody.outputDimensionality === undefined) { - const outputDimensionality = Number(body.dimensions); + if (runtime.provider === "gemini" && upstreamBody.outputDimensionality === undefined) { + const outputDimensionality = Number(runtime.body.dimensions); if (Number.isFinite(outputDimensionality) && outputDimensionality > 0) { upstreamBody.outputDimensionality = outputDimensionality; } } - - // Inject model-level default params (e.g. NVIDIA NIM asymmetric models require - // `input_type`) only for keys the client did not already supply, so a - // client-sent value is never overwritten. Symmetric models carry no defaults - // and are unaffected. See issue #1378. - const defaultParams = getEmbeddingModelDefaultParams(providerConfig, model); - if (defaultParams) { - for (const [key, value] of Object.entries(defaultParams)) { - if (upstreamBody[key] === undefined) { - upstreamBody[key] = value; - } - } + const defaultParams = getEmbeddingModelDefaultParams(runtime.providerConfig, runtime.model); + for (const [key, value] of Object.entries(defaultParams ?? {})) { + if (upstreamBody[key] === undefined) upstreamBody[key] = value; } + return upstreamBody; +} - let upstreamUrl = providerConfig.baseUrl; - if (provider === "ollama-local" || provider === "lmstudio") { - // Keyless local servers (#2824 ollama-local, #11233 lmstudio): honor the - // configured connection's baseUrl when one was hydrated, and fall back to - // the static localhost registry default otherwise. - const configuredBaseUrl = credentials?.providerSpecificData?.baseUrl; - const rawBaseUrl = - typeof configuredBaseUrl === "string" && configuredBaseUrl.trim().length > 0 - ? configuredBaseUrl - : providerConfig.baseUrl; - // Use the shared O(n) helper instead of `/\/+$/` — that regex is - // vulnerable to polynomial backtracking on adversarial input - // (CodeQL js/polynomial-redos) since baseUrl is operator-configured - // per-connection data. See open-sse/utils/urlSanitize.ts. - const normalizedBaseUrl = stripTrailingSlashes(rawBaseUrl.trim()); - const localServerHost = normalizedBaseUrl - .replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "") - .replace(/\/api\/chat$/i, "") - .replace(/\/v1$/i, ""); - upstreamUrl = `${localServerHost}/v1/embeddings`; - } - let normalizeProviderResponse: - ((data: Record) => Record) | null = null; +function resolveLocalEmbeddingUrl(runtime: EmbeddingRuntime): string { + const configuredBaseUrl = runtime.credentials?.providerSpecificData?.baseUrl; + const rawBaseUrl = + typeof configuredBaseUrl === "string" && configuredBaseUrl.trim() + ? configuredBaseUrl + : runtime.providerConfig.baseUrl; + const localServerHost = stripTrailingSlashes(rawBaseUrl.trim()) + .replace(/\/v1\/(?:chat\/completions|embeddings)$/i, "") + .replace(/\/api\/chat$/i, "") + .replace(/\/v1$/i, ""); + return `${localServerHost}/v1/embeddings`; +} - // Build headers - const headers: Record = { - "Content-Type": "application/json", - }; +function resolveUpstreamUrl(runtime: EmbeddingRuntime): string { + return runtime.provider === "ollama-local" || runtime.provider === "lmstudio" + ? resolveLocalEmbeddingUrl(runtime) + : runtime.providerConfig.baseUrl; +} - // Skip credential injection for local providers (authType: "none") +function buildAuth( + runtime: EmbeddingRuntime +): { headers: Record; token: string | null } | EmbeddingFailure { + const headers: Record = { "Content-Type": "application/json" }; const token = - providerConfig.authType === "none" ? null : credentials?.apiKey || credentials?.accessToken; - if (token) { - if (providerConfig.authHeader === "bearer") { - headers["Authorization"] = `Bearer ${token}`; - } else if (providerConfig.authHeader === "x-api-key") { - headers["x-api-key"] = token; - } - } else if (providerConfig.authType !== "none") { - return { - success: false, - status: 401, - error: `No valid authentication token for provider ${provider}. Check provider credentials.`, - }; - } - - // 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, - status: 400, - error: `Invalid embedding model: ${body.model}. Use format: provider/model`, - }; - } - try { - const prepared = await prepareStructuredEmbeddingRequest( - providerConfig, - model, - body, - token ?? "", - { - fetchMedia: 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 }; - }, - } - ); - upstreamBody = prepared.body; - upstreamUrl = prepared.url; - normalizeProviderResponse = prepared.normalizeResponse ?? null; - if (prepared.authHeader) { - delete headers.Authorization; - delete headers["x-api-key"]; - headers[prepared.authHeader.name] = prepared.authHeader.value; - } - } catch (error) { - return { success: false, status: 400, error: sanitizeErrorMessage(error) }; - } - } - - if (log) { - log.info( - "EMBED", - `${provider}/${model} | input: ${Array.isArray(body.input) ? body.input.length + " items" : "1 item"}` + runtime.providerConfig.authType === "none" + ? null + : runtime.credentials?.apiKey || runtime.credentials?.accessToken || null; + if (!token && runtime.providerConfig.authType !== "none") { + return failure( + 401, + `No valid authentication token for provider ${runtime.provider}. Check provider credentials.` ); } + if (token && runtime.providerConfig.authHeader === "bearer") { + headers.Authorization = `Bearer ${token}`; + } else if (token && runtime.providerConfig.authHeader === "x-api-key") { + headers["x-api-key"] = token; + } + return { headers, token }; +} - try { - // Quota share enforcement (fail-open: errors allow the request through) - if (apiKeyId && connectionId && provider) { - try { - const { enforceQuotaShare } = await import("@/lib/quota/enforce"); - const quotaDecision = await enforceQuotaShare({ - apiKeyId, - connectionId, - provider, - // Per-(key,model) cap — resolved embedding model id (same scope used in logs/routing). - model: model || undefined, - }); - if (quotaDecision.kind === "block") { - return { - success: false, - status: quotaDecision.httpStatus ?? 429, - error: quotaDecision.reason || "Quota share limit reached", - }; - } - } catch { - // fail-open per B16 - } - } +async function fetchEmbeddingMedia( + url: string +): Promise<{ buffer: Buffer; contentType: string | null }> { + const result = await fetchRemoteImage(url, { + guard: "public-only", + maxBytes: MAX_EMBEDDING_INLINE_ITEM_BYTES, + pinDns: true, + }); + return { buffer: result.buffer, contentType: result.contentType || null }; +} - // Log provider request - reqLogger.logTargetRequest(upstreamUrl, headers, upstreamBody); +async function prepareMixedJinaInput( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest +): Promise { + const mixed = Array.isArray(runtime.body.input) ? runtime.body.input : [runtime.body.input]; + prepared.upstreamBody.input = await prepareJinaMixedEmbeddingInput(mixed, fetchEmbeddingMedia); +} - const response = await fetch(upstreamUrl, { - method: "POST", - headers, - body: JSON.stringify(upstreamBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - if (log) { - log.error("EMBED", `${provider} error ${response.status}: ${errorText.slice(0, 200)}`); - } - - // Log provider response - reqLogger.logProviderResponse(response.status, "", response.headers, errorText.slice(0, 500)); - - // Build client error response - const clientErrorBody = toJsonErrorPayload( - errorText.slice(0, 500), - "Embedding provider error" - ); - reqLogger.logConvertedResponse(clientErrorBody); - - const pipelinePayloads = detailedLoggingEnabled ? reqLogger.getPipelinePayloads() : null; - - // Save error call log for Logger panel - saveCallLog({ - method: "POST", - path: "/v1/embeddings", - status: response.status, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: errorText.slice(0, 500), - requestBody: logRequestBody, - pipelinePayloads, - apiKeyId, - apiKeyName, - connectionId, - }).catch(() => {}); - - // #10347 — persist a connection-level failure marker on a hard upstream failure so - // the dead account is not re-selected and re-hit on the next embed request (chat - // parity). markAccountUnavailable classifies the status via checkFallbackError: a - // payment-required 402 becomes the TERMINAL state credits_exhausted (the terminal - // marker excludes the account from selection until an operator resets it), benign - // 4xx are a no-op, and terminal statuses are never overwritten. honors per-connection - // disableCooling. The write must never break the error response path, so it is - // best-effort. - if (connectionId) { - try { - await markAccountUnavailable(connectionId, response.status, errorText, provider, model); - } catch { - // swallow — the upstream error response takes priority - } - } - - return { - success: false, - status: response.status, - error: errorText, - headers: stripStaleEncodingHeaders(response.headers), - }; - } - - const rawData = (await response.json()) as Record; - const data = (normalizeProviderResponse ? normalizeProviderResponse(rawData) : rawData) as { - data?: unknown[] | unknown; - usage?: { prompt_tokens?: number; total_tokens?: number }; - }; - - // Log provider response - reqLogger.logProviderResponse(response.status, "", response.headers, data); - - // OpenAI-spec compliance (#9089): each item's `embedding` must be a flat number[]. - // Some OpenAI-compatible backends (e.g. a llama.cpp `llama-server --embedding` - // instance) return the vector wrapped in one extra array level — `[[...floats]]` - // instead of `[...floats]` — for a single input, which silently breaks any standard - // OpenAI-SDK consumer doing `response.data[i].embedding`. Flatten that one redundant - // level without touching providers that already return flat vectors. - const responseItems = data.data || data; - if (Array.isArray(responseItems)) { - for (const item of responseItems) { - flattenSingleRowEmbedding(item); - } - } - - // Normalize response to OpenAI format - const normalizedResponse = { - object: "list", - data: data.data || data, - model: `${provider}/${model}`, - usage: data.usage || { prompt_tokens: 0, total_tokens: 0 }, - }; - - // Log client response - reqLogger.logConvertedResponse(normalizedResponse); - - const pipelinePayloads = detailedLoggingEnabled ? reqLogger.getPipelinePayloads() : null; - - // Save success call log for Logger panel - // Embeddings only have input tokens (prompt_tokens + total_tokens), no output/completion tokens - saveCallLog({ - method: "POST", - path: "/v1/embeddings", - status: 200, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - tokens: { - prompt_tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, - completion_tokens: 0, - }, - requestBody: logRequestBody, - responseBody: { - usage: data.usage || null, - object: "list", - data_count: Array.isArray(data.data) ? data.data.length : 0, - }, - pipelinePayloads, - apiKeyId, - apiKeyName, - connectionId, - }).catch(() => {}); - - // Record quota consumption (fire-and-forget, never blocks) - if (apiKeyId && connectionId && provider) { - try { - const { scheduleRecordConsumption } = await import("@/lib/quota/spendRecorder"); - scheduleRecordConsumption({ - apiKeyId, - connectionId, - provider, - // Per-(key,model) cap accounting — same resolved model id used at enforce time. - model: model || undefined, - cost: { - tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, - requests: 1, - }, - }); - } catch { - // fail-open per B29 - } - } - - return { - success: true, - data: normalizedResponse, - headers: stripStaleEncodingHeaders(response.headers), - }; - } catch (err) { - if (log) { - log.error("EMBED", `${provider} fetch error: ${err.message}`); - } - - // Log error - reqLogger.logError(err, upstreamBody); - - const pipelinePayloads = detailedLoggingEnabled ? reqLogger.getPipelinePayloads() : null; - - // Save exception call log for Logger panel - saveCallLog({ - method: "POST", - path: "/v1/embeddings", - status: 502, - model: `${provider}/${model}`, - provider, - duration: Date.now() - startTime, - error: err.message, - requestBody: logRequestBody, - pipelinePayloads, - apiKeyId, - apiKeyName, - connectionId, - }).catch(() => {}); - - return { - success: false, - status: 502, - error: `Embedding provider error: ${sanitizeErrorMessage(err.message)}`, - }; +async function prepareNativeTransport( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + token: string | null +): Promise { + if (!runtime.model) { + throw new Error(`Invalid embedding model: ${runtime.body.model}. Use format: provider/model`); + } + const native = await prepareStructuredEmbeddingRequest( + runtime.providerConfig, + runtime.model, + runtime.body, + token ?? "", + { fetchMedia: fetchEmbeddingMedia } + ); + prepared.upstreamBody = native.body; + prepared.upstreamUrl = native.url; + prepared.normalizeProviderResponse = native.normalizeResponse ?? null; + if (native.authHeader) { + delete prepared.headers.Authorization; + delete prepared.headers["x-api-key"]; + prepared.headers[native.authHeader.name] = native.authHeader.value; } } + +async function applyStructuredTransport( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + token: string | null +): Promise { + const jinaNative = isJinaNativeEmbeddingInput(runtime.body.input); + const geminiNative = isGeminiNativeEmbeddingInput(runtime.body.input); + const canonical = hasStructuredEmbeddingInput(runtime.body.input); + const isJinaProtocol = runtime.providerConfig.structuredInputProtocol === "jina-v1"; + const passThroughJina = isJinaProtocol && jinaNative && !canonical; + const useGeminiNative = + runtime.providerConfig.structuredInputProtocol === "gemini-embed-content" && + (isGeminiEmbedding2Family(runtime.model) || canonical || geminiNative || jinaNative); + + if (isJinaProtocol && jinaNative && canonical) { + await prepareMixedJinaInput(runtime, prepared); + } else if (useGeminiNative || (!passThroughJina && canonical)) { + await prepareNativeTransport(runtime, prepared, token); + } +} + +async function prepareEmbeddingRequest( + runtime: EmbeddingRuntime +): Promise { + const auth = buildAuth(runtime); + if ("success" in auth) return auth; + const prepared: PreparedEmbeddingRequest = { + upstreamBody: buildUpstreamBody(runtime), + upstreamUrl: resolveUpstreamUrl(runtime), + headers: auth.headers, + normalizeProviderResponse: null, + }; + try { + await applyStructuredTransport(runtime, prepared, auth.token); + return prepared; + } catch (error) { + return failure(400, sanitizeErrorMessage(error)); + } +} + +async function enforceEmbeddingQuota(runtime: EmbeddingRuntime): Promise { + if (!runtime.apiKeyId || !runtime.connectionId) return null; + try { + const { enforceQuotaShare } = await import("@/lib/quota/enforce"); + const decision = await enforceQuotaShare({ + apiKeyId: runtime.apiKeyId, + connectionId: runtime.connectionId, + provider: runtime.provider, + model: runtime.model || undefined, + }); + return decision.kind === "block" + ? failure(decision.httpStatus ?? 429, decision.reason || "Quota share limit reached") + : null; + } catch { + return null; + } +} + +function resolveSingleTexts(runtime: EmbeddingRuntime): string[] | EmbeddingFailure | null { + if (runtime.providerConfig.singleTextProtocol !== "clova-v2") return null; + const input = Array.isArray(runtime.body.input) ? runtime.body.input : [runtime.body.input]; + if ( + input.length === 0 || + input.some((item) => typeof item !== "string" || item.trim().length === 0) + ) { + return failure(400, "CLOVA Studio embedding v2 accepts non-empty text strings only"); + } + if (runtime.body.encoding_format === "base64") { + return failure(400, "CLOVA Studio embedding v2 supports float encoding only"); + } + if (runtime.body.dimensions !== undefined && Number(runtime.body.dimensions) !== 1024) { + return failure(400, "CLOVA Studio embedding v2 has a fixed dimension of 1024"); + } + return input as string[]; +} + +function appendClovaEmbedding( + parsed: ParsedEmbeddingResponse, + embeddings: Array>, + usage: { prompt_tokens: number; total_tokens: number } +): void { + if (!Array.isArray(parsed.data)) { + throw new Error("CLOVA Studio embedding v2 returned an invalid data list"); + } + for (const item of parsed.data) { + flattenSingleRowEmbedding(item); + if (!item || typeof item !== "object") { + throw new Error("CLOVA Studio embedding v2 returned an invalid embedding item"); + } + (item as { index?: number }).index = embeddings.length; + embeddings.push(item as Record); + } + usage.prompt_tokens += parsed.usage?.prompt_tokens || parsed.usage?.total_tokens || 0; + usage.total_tokens += parsed.usage?.total_tokens || parsed.usage?.prompt_tokens || 0; +} + +async function fetchClovaEmbeddingBatch( + prepared: PreparedEmbeddingRequest, + texts: string[], + reqLogger: RequestLogger +): Promise { + const embeddings: Array> = []; + const usage = { prompt_tokens: 0, total_tokens: 0 }; + let lastHeaders = new Headers(); + for (const text of texts) { + const requestBody = { text }; + reqLogger.logTargetRequest(prepared.upstreamUrl, prepared.headers, requestBody); + const response = await fetch(prepared.upstreamUrl, { + method: "POST", + headers: prepared.headers, + body: JSON.stringify(requestBody), + }); + lastHeaders = response.headers; + if (!response.ok) return response; + const rawData = (await response.json()) as Record; + appendClovaEmbedding(normalizeClovaEmbeddingV2Response(rawData), embeddings, usage); + } + return new Response(JSON.stringify({ data: embeddings, usage }), { + status: 200, + headers: lastHeaders, + }); +} + +async function dispatchEmbeddingRequest( + prepared: PreparedEmbeddingRequest, + singleTexts: string[] | null, + reqLogger: RequestLogger +): Promise { + if (singleTexts) return fetchClovaEmbeddingBatch(prepared, singleTexts, reqLogger); + reqLogger.logTargetRequest(prepared.upstreamUrl, prepared.headers, prepared.upstreamBody); + return fetch(prepared.upstreamUrl, { + method: "POST", + headers: prepared.headers, + body: JSON.stringify(prepared.upstreamBody), + }); +} + +function pipelinePayloads( + runtime: EmbeddingRuntime +): ReturnType | null { + return runtime.detailedLoggingEnabled ? runtime.reqLogger.getPipelinePayloads() : null; +} + +async function handleUpstreamFailure( + runtime: EmbeddingRuntime, + response: Response +): Promise { + const errorText = await response.text(); + runtime.log?.error( + "EMBED", + `${runtime.provider} error ${response.status}: ${errorText.slice(0, 200)}` + ); + runtime.reqLogger.logProviderResponse( + response.status, + "", + response.headers, + errorText.slice(0, 500) + ); + runtime.reqLogger.logConvertedResponse( + toJsonErrorPayload(errorText.slice(0, 500), "Embedding provider error") + ); + saveCallLog({ + method: "POST", + path: "/v1/embeddings", + status: response.status, + model: `${runtime.provider}/${runtime.model}`, + provider: runtime.provider, + duration: Date.now() - runtime.startTime, + error: errorText.slice(0, 500), + requestBody: runtime.logRequestBody, + pipelinePayloads: pipelinePayloads(runtime), + apiKeyId: runtime.apiKeyId, + apiKeyName: runtime.apiKeyName, + connectionId: runtime.connectionId, + }).catch(() => {}); + if (runtime.connectionId) { + try { + await markAccountUnavailable( + runtime.connectionId, + response.status, + errorText, + runtime.provider, + runtime.model + ); + } catch { + // The upstream response has priority over a best-effort cooldown write. + } + } + return failure(response.status, errorText, stripStaleEncodingHeaders(response.headers)); +} + +function normalizeEmbeddingData( + runtime: EmbeddingRuntime, + response: Response, + rawData: Record, + normalizer: ProviderResponseNormalizer +): { data: ParsedEmbeddingResponse; normalizedResponse: Record } { + const data = (normalizer ? normalizer(rawData) : rawData) as ParsedEmbeddingResponse; + runtime.reqLogger.logProviderResponse(response.status, "", response.headers, data); + const responseItems = data.data || data; + if (Array.isArray(responseItems)) responseItems.forEach(flattenSingleRowEmbedding); + return { + data, + normalizedResponse: { + object: "list", + data: data.data || data, + model: `${runtime.provider}/${runtime.model}`, + usage: data.usage || { prompt_tokens: 0, total_tokens: 0 }, + }, + }; +} + +function recordEmbeddingSuccess( + runtime: EmbeddingRuntime, + data: ParsedEmbeddingResponse, + normalizedResponse: Record +): void { + runtime.reqLogger.logConvertedResponse(normalizedResponse); + saveCallLog({ + method: "POST", + path: "/v1/embeddings", + status: 200, + model: `${runtime.provider}/${runtime.model}`, + provider: runtime.provider, + duration: Date.now() - runtime.startTime, + tokens: { + prompt_tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, + completion_tokens: 0, + }, + requestBody: runtime.logRequestBody, + responseBody: { + usage: data.usage || null, + object: "list", + data_count: Array.isArray(data.data) ? data.data.length : 0, + }, + pipelinePayloads: pipelinePayloads(runtime), + apiKeyId: runtime.apiKeyId, + apiKeyName: runtime.apiKeyName, + connectionId: runtime.connectionId, + }).catch(() => {}); +} + +async function recordEmbeddingConsumption( + runtime: EmbeddingRuntime, + data: ParsedEmbeddingResponse, + requestCount: number +): Promise { + if (!runtime.apiKeyId || !runtime.connectionId) return; + try { + const { scheduleRecordConsumption } = await import("@/lib/quota/spendRecorder"); + scheduleRecordConsumption({ + apiKeyId: runtime.apiKeyId, + connectionId: runtime.connectionId, + provider: runtime.provider, + model: runtime.model || undefined, + cost: { + tokens: data.usage?.prompt_tokens || data.usage?.total_tokens || 0, + requests: requestCount, + }, + }); + } catch { + // Quota accounting is fail-open. + } +} + +async function handleUpstreamSuccess( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + response: Response, + requestCount: number +): Promise { + const rawData = (await response.json()) as Record; + const { data, normalizedResponse } = normalizeEmbeddingData( + runtime, + response, + rawData, + prepared.normalizeProviderResponse + ); + recordEmbeddingSuccess(runtime, data, normalizedResponse); + await recordEmbeddingConsumption(runtime, data, requestCount); + return { + success: true, + data: normalizedResponse, + headers: stripStaleEncodingHeaders(response.headers), + }; +} + +function handleEmbeddingException( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest, + error: unknown +): EmbeddingFailure { + const message = error instanceof Error ? error.message : String(error); + runtime.log?.error("EMBED", `${runtime.provider} fetch error: ${message}`); + runtime.reqLogger.logError(error, prepared.upstreamBody); + saveCallLog({ + method: "POST", + path: "/v1/embeddings", + status: 502, + model: `${runtime.provider}/${runtime.model}`, + provider: runtime.provider, + duration: Date.now() - runtime.startTime, + error: message, + requestBody: runtime.logRequestBody, + pipelinePayloads: pipelinePayloads(runtime), + apiKeyId: runtime.apiKeyId, + apiKeyName: runtime.apiKeyName, + connectionId: runtime.connectionId, + }).catch(() => {}); + return failure(502, `Embedding provider error: ${sanitizeErrorMessage(message)}`); +} + +async function executeEmbedding( + runtime: EmbeddingRuntime, + prepared: PreparedEmbeddingRequest +): Promise { + const quotaFailure = await enforceEmbeddingQuota(runtime); + if (quotaFailure) return quotaFailure; + const singleTextsOrFailure = resolveSingleTexts(runtime); + if (singleTextsOrFailure && !Array.isArray(singleTextsOrFailure)) return singleTextsOrFailure; + const singleTexts = Array.isArray(singleTextsOrFailure) ? singleTextsOrFailure : null; + try { + const response = await dispatchEmbeddingRequest(prepared, singleTexts, runtime.reqLogger); + return response.ok + ? handleUpstreamSuccess(runtime, prepared, response, singleTexts?.length ?? 1) + : handleUpstreamFailure(runtime, response); + } catch (error) { + return handleEmbeddingException(runtime, prepared, error); + } +} + +/** Handle one OpenAI-compatible embedding request. */ +export async function handleEmbedding(params: HandleEmbeddingParams): Promise { + const resolved = resolveEmbedding(params); + const runtime = await createEmbeddingRuntime(params, resolved); + if ("success" in runtime) return runtime; + const modalityFailure = validateRequestedModalities(runtime); + if (modalityFailure) return modalityFailure; + const prepared = await prepareEmbeddingRequest(runtime); + if ("success" in prepared) return prepared; + runtime.log?.info( + "EMBED", + `${runtime.provider}/${runtime.model} | input: ${ + Array.isArray(runtime.body.input) ? `${runtime.body.input.length} items` : "1 item" + }` + ); + return executeEmbedding(runtime, prepared); +} diff --git a/open-sse/handlers/responseSanitizer.ts b/open-sse/handlers/responseSanitizer.ts index ce2d2af227..66d00d099b 100644 --- a/open-sse/handlers/responseSanitizer.ts +++ b/open-sse/handlers/responseSanitizer.ts @@ -1061,6 +1061,7 @@ function convertOpenAIResponseToResponses(openaiResponse: JsonRecord): JsonRecor /** * Sanitize a streaming SSE chunk for passthrough mode. * Lighter than full sanitization — only strips problematic extra fields. + * Fast-path: returns original when no mutations are needed. */ export function sanitizeStreamingChunk(parsed: unknown): unknown { const parsedRecord = toRecord(parsed); @@ -1078,14 +1079,29 @@ export function sanitizeStreamingChunk(parsed: unknown): unknown { if (eventType === "content_block_delta") { const deltaRecord = toRecord(parsedRecord.delta); if (deltaRecord) { + let mutated = false; if (typeof deltaRecord.text === "string") { deltaRecord.text = stripZeroWidthText(deltaRecord.text); + mutated = true; } if (typeof deltaRecord.thinking === "string") { deltaRecord.thinking = stripZeroWidthText(deltaRecord.thinking); + mutated = true; } + return mutated ? parsedRecord : parsed; } - return parsedRecord; + return parsed; + } + + // Fast-path: check if any mutations would actually be needed + // Most passthrough chunks (content deltas) need no sanitization + const needsIdNormalization = parsedRecord.id !== undefined && parsedRecord.id !== null && typeof parsedRecord.id !== "string"; + const hasChoices = Array.isArray(parsedRecord.choices) && parsedRecord.choices.length > 0; + const hasUsage = parsedRecord.usage !== undefined; + const hasSystemFingerprint = parsedRecord.system_fingerprint !== undefined; + if (!needsIdNormalization && !hasChoices && !hasUsage && !hasSystemFingerprint) { + // Nothing to sanitize — forward original + return parsed; } // Build sanitized chunk diff --git a/open-sse/mcp-server/__tests__/a2aLifecycle.test.ts b/open-sse/mcp-server/__tests__/a2aLifecycle.test.ts index 470dc1f2bd..b660006a0f 100644 --- a/open-sse/mcp-server/__tests__/a2aLifecycle.test.ts +++ b/open-sse/mcp-server/__tests__/a2aLifecycle.test.ts @@ -1,11 +1,21 @@ import { afterEach, describe, expect, it } from "vitest"; -import { A2ATaskManager } from "../../../src/lib/a2a/taskManager.ts"; +import { A2ATaskManager, type A2APersistence } from "../../../src/lib/a2a/taskManager.ts"; import { executeA2ATaskWithState } from "../../../src/lib/a2a/taskExecution.ts"; const managers: A2ATaskManager[] = []; +// Default persistence opens SQLite (167 migrations) inside the vitest thread pool. +// Tests inject a no-op so they never touch the DB (same seam as a2a-task-persistence.test.ts). +function noopPersistence(): A2APersistence { + return { + upsert: (() => {}) as A2APersistence["upsert"], + appendEvent: (() => {}) as A2APersistence["appendEvent"], + purge: ((): number => 0) as A2APersistence["purge"], + }; +} + function createManager(ttlMinutes = 5) { - const manager = new A2ATaskManager(ttlMinutes); + const manager = new A2ATaskManager(ttlMinutes, noopPersistence()); managers.push(manager); return manager; } @@ -44,9 +54,14 @@ describe("A2A task lifecycle regressions", () => { tm.updateTask(task.id, "working"); await expect( - executeA2ATaskWithState(tm, task, async () => { - throw new Error("upstream failure"); - }) + executeA2ATaskWithState( + tm, + task, + async () => { + throw new Error("upstream failure"); + }, + { search: async () => [], appendEvent: () => {} } + ) ).rejects.toThrow("upstream failure"); const loaded = tm.getTask(task.id); diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 36f1c8b766..c6465321f9 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -49,7 +49,8 @@ import { } from "../../src/shared/constants/providers"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; -import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; +import { getQuotaScopedModelForProvider, isAntigravityQuotaProvider } from "./antigravityQuotaFamily.ts"; +import { persistAntigravityFamilyCooldownIfQuota } from "./antigravityFamilyCooldown.ts"; import { classifyGeminiQuotaMetricFromText, isRpdExhausted, @@ -63,6 +64,18 @@ import { parseDelayString, MAX_SHORT_RETRY_HINT_MS, } from "./retryAfterJson.ts"; +import { isMoonshotAccountBalanceExhausted } from "./usage/moonshotOpenPlatform.ts"; +import { isTpdRateLimit, resolveTpdCooldownMs } from "./dailyQuotaReset.ts"; + +// Pre-compiled regex constants for hot-path retry parsing (avoid per-call compilation) +const RETRY_AFTER_RE = /retry\s+after\s+(\d+)\s*s/i; +const PLEASE_RETRY_RE = /please retry in\s+([\d.]+\s*s)/i; +const ISO_RETRY_RE = /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i; +const RESETS_AFTER_RE = /resets? after (\d+h)?(\d+m)?(\d+s)?/i; +const WILL_RESET_AFTER_RE = /will reset after (\d+h)?(\d+m)?(\d+s)?/i; +const RESETS_IN_RE = /resets? in (\d+h)?(\d+m)?(\d+s)?/i; +const RETRY_IN_SEC_RE = /please retry in (\d+(?:\.\d+)?)\s*s/i; +const COOLDOWN_NUMERIC_RE = /^\d+(\.\d+)?$/; export type RetryHintProvenance = "header" | "google_rpc_retry_info" | "body"; @@ -640,6 +653,9 @@ export async function recordCoreOwnedAntigravityQuotaState({ exactCooldownIsUpstreamReset: retryHintBypassesMaxCooldownMs(fallback.retryHintSource), } ); + if (lockout.cooldownMs > 0 && isProviderExhaustedReason(fallback)) { + persistAntigravityFamilyCooldownIfQuota({ provider, connectionId, model, cooldownMs: lockout.cooldownMs, reason: "quota_exhausted" }); + } return { cooldownMs: lockout.cooldownMs, failureCount: lockout.failureCount }; } @@ -1371,7 +1387,7 @@ export function parseRetryAfterFromBody(responseBody: unknown): { // OpenAI: "Please retry after 20s" in message const msg = String(error.message || body.message || ""); - const retryMatch = /retry\s+after\s+(\d+)\s*s/i.exec(msg); + const retryMatch = RETRY_AFTER_RE.exec(msg); if (retryMatch) { return { retryAfterMs: Number.parseInt(retryMatch[1], 10) * 1000, @@ -1404,16 +1420,13 @@ export function parseRetryFromErrorText(errorText: unknown): number | null { // Gemini free-tier text fallback (no parseable JSON details present): // "Please retry in 26.660853464s." Short throttle hint — capped independently of // MAX_PROVIDER_COOLDOWN_MS, mirroring the JSON RetryInfo.retryDelay cap (#7940). - const pleaseRetryMs = parseDelayString(/please retry in\s+([\d.]+\s*s)/i.exec(msg)?.[1]); + const pleaseRetryMs = parseDelayString(PLEASE_RETRY_RE.exec(msg)?.[1]); if (pleaseRetryMs !== null && pleaseRetryMs > 0) { return Math.min(pleaseRetryMs, MAX_SHORT_RETRY_HINT_MS); } // Issue #2321: parse embedded absolute ISO retry timestamps. - const isoMatch = - /\b(?:try again at|wait until|reset(?:s)? at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)/i.exec( - msg - ); + const isoMatch = ISO_RETRY_RE.exec(msg); if (isoMatch) { const parsedTs = Date.parse(isoMatch[1]); if (Number.isFinite(parsedTs)) { @@ -1422,21 +1435,21 @@ export function parseRetryFromErrorText(errorText: unknown): number | null { } } - const match = /resets? after (\d+h)?(\d+m)?(\d+s)?/i.exec(msg); + const match = RESETS_AFTER_RE.exec(msg); if (match?.[1] || match?.[2] || match?.[3]) return computeDurationMs(match); // Variant without "reset after": "will reset after XhYmZs" - const altMatch = /will reset after (\d+h)?(\d+m)?(\d+s)?/i.exec(msg); + const altMatch = WILL_RESET_AFTER_RE.exec(msg); if (altMatch?.[1] || altMatch?.[2] || altMatch?.[3]) return computeDurationMs(altMatch); // Antigravity / Cloud Code phrasing: "Resets in 164h27m24s". - const resetsInMatch = /resets? in (\d+h)?(\d+m)?(\d+s)?/i.exec(msg); + const resetsInMatch = RESETS_IN_RE.exec(msg); if (resetsInMatch?.[1] || resetsInMatch?.[2] || resetsInMatch?.[3]) { return computeDurationMs(resetsInMatch); } // Gemini phrasing: "Please retry in 54.472178091s" (fractional seconds). - const retryInSecMatch = /please retry in (\d+(?:\.\d+)?)\s*s/i.exec(msg); + const retryInSecMatch = RETRY_IN_SEC_RE.exec(msg); if (retryInSecMatch?.[1]) { const sec = Number.parseFloat(retryInSecMatch[1]); if (Number.isFinite(sec) && sec > 0) { @@ -1594,7 +1607,8 @@ export function isDailyQuotaExhausted(errorText: string): boolean { return ( lower.includes("today's quota") || lower.includes("daily quota") || - lower.includes("try again tomorrow") + lower.includes("try again tomorrow") || + lower.includes("tpd rate limit") ); } @@ -1640,7 +1654,12 @@ export function checkFallbackError( headers: Headers | Record | null = null, profileOverride: ProviderProfile | null = null, structuredError?: { code?: string | null; type?: string | null } | null, - rotation?: { account?: unknown } | null + rotation?: { account?: unknown } | null, + dailyReset?: { + timezone?: unknown; + hour?: unknown; + nowMs?: number; + } | null, ): { shouldFallback: boolean; cooldownMs: number; @@ -1923,8 +1942,13 @@ export function checkFallbackError( } } - // T10 (sub2api #1169) + #8247: credits/quota exhausted; *-compatible-* nicknames stay model-scoped. - if (shouldUseQuotaSignal && isCreditsExhausted(errorStr) && !isCompatibleProvider(provider)) { + // T10 (sub2api #1169) + #8247: credits/quota exhausted; *-compatible-* nicknames stay model-scoped + // unless the body is an account-level Open Platform empty wallet. + if ( + shouldUseQuotaSignal && + isCreditsExhausted(errorStr) && + (!isCompatibleProvider(provider) || isMoonshotAccountBalanceExhausted(errorStr)) + ) { return { shouldFallback: true, cooldownMs: COOLDOWN_MS.paymentRequired ?? 3600 * 1000, // 1h cooldown @@ -1933,17 +1957,43 @@ export function checkFallbackError( }; } - // Daily quota exhausted — lock model until tomorrow + // Daily quota exhausted. TPD uses the node clock / header; other daily + // quota text still uses getMsUntilTomorrow. TPD without either is not a + // host-midnight lock — fall through to short 429. if (shouldUseQuotaSignal && isDailyQuotaExhausted(errorStr)) { - const msUntilTomorrow = getMsUntilTomorrow(); - // Cap at 24 hours to handle timezone edge cases - const cooldownMs = Math.min(msUntilTomorrow, 24 * 60 * 60 * 1000); - return { - shouldFallback: true, - cooldownMs, - reason: RateLimitReason.QUOTA_EXHAUSTED, - dailyQuotaExhausted: true, - }; + if (isTpdRateLimit(errorStr)) { + const headerResetAtMs = parseResetFromHeaders(headers); + const tpdMs = resolveTpdCooldownMs(errorStr, { + timezone: dailyReset?.timezone, + hour: dailyReset?.hour, + nowMs: dailyReset?.nowMs, + headerResetAtMs, + }); + if (tpdMs == null) { + // no clock, no header — short 429, do not guess midnight + console.warn( + "[accountFallback] TPD 429 without node daily-reset clock or Reset header; using short cooldown", + { provider }, + ); + } else { + return { + shouldFallback: true, + cooldownMs: tpdMs, + reason: RateLimitReason.QUOTA_EXHAUSTED, + dailyQuotaExhausted: true, + }; + } + } else { + const msUntilTomorrow = getMsUntilTomorrow(); + // Cap at 24 hours to handle timezone edge cases + const cooldownMs = Math.min(msUntilTomorrow, 24 * 60 * 60 * 1000); + return { + shouldFallback: true, + cooldownMs, + reason: RateLimitReason.QUOTA_EXHAUSTED, + dailyQuotaExhausted: true, + }; + } } // Issue #2321 (5h subscription quota) + Issue #3709 (ollama-cloud weekly @@ -2226,7 +2276,7 @@ export function cooldownUntilMs(value: string | number | Date | null | undefined if (value instanceof Date) return value.getTime(); if (typeof value === "number") return value; const raw = value.trim(); - if (/^\d+(\.\d+)?$/.test(raw)) return Number(raw); + if (COOLDOWN_NUMERIC_RE.test(raw)) return Number(raw); return new Date(raw).getTime(); } @@ -2382,12 +2432,7 @@ export function applyErrorState( // (`markConnectionQuotaExhausted`) so a DB failure can never crash the // chat path. See issue #1 (per-account 429 cascade not persisting). const connId = (account as AccountState | null | undefined)?.id; - if ( - typeof connId === "string" && - connId.length > 0 && - effectiveCooldownMs > 0 && - nextState.rateLimitedUntil - ) { + if (typeof connId === "string" && connId.length > 0 && effectiveCooldownMs > 0 && nextState.rateLimitedUntil && !isAntigravityQuotaProvider(prov)) { try { const untilMs = cooldownUntilMs(nextState.rateLimitedUntil); if (Number.isFinite(untilMs) && untilMs > Date.now()) { diff --git a/open-sse/services/antigravityFamilyCooldown.ts b/open-sse/services/antigravityFamilyCooldown.ts new file mode 100644 index 0000000000..975822a88f --- /dev/null +++ b/open-sse/services/antigravityFamilyCooldown.ts @@ -0,0 +1,158 @@ +/** + * Persist Antigravity/agy quota cooldowns per model family (gemini vs claude) + * on the connection row, without cooling the whole account. + */ +import { lockModel } from "./accountFallback.ts"; +import { + getAntigravityQuotaFamily, + isAntigravityQuotaProvider, +} from "./antigravityQuotaFamily.ts"; + +type JsonRecord = Record; + +const FAMILY_PSD_KEY = "antigravityFamilyRateLimitedUntil"; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : {}; +} + +function parseUntilMs(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const ms = /^\d+(\.\d+)?$/.test(value.trim()) ? Number(value) : Date.parse(value); + return Number.isFinite(ms) ? ms : NaN; + } + return NaN; +} + +function dummyModelForFamily(family: "gemini" | "claude"): string { + return family === "gemini" ? "gemini-family-lock" : "claude-family-lock"; +} + +function lockAntigravityFamilyModel( + connectionId: string, + model: string, + reason: string, + cooldownMs: number +): void { + lockModel("agy", connectionId, model, reason, cooldownMs); + lockModel("antigravity", connectionId, model, reason, cooldownMs); +} + +export async function persistAntigravityFamilyCooldown(params: { + connectionId: string; + model: string; + rateLimitedUntil: string; +}): Promise { + if (!params.model.trim()) return null; + const family = getAntigravityQuotaFamily(params.model); + if (family === "other") return null; + + const { getProviderConnectionById, updateProviderConnection } = await import( + "@/lib/db/providers" + ); + const conn = (await getProviderConnectionById(params.connectionId)) as + | { provider?: string; providerSpecificData?: JsonRecord | null } + | null; + if (!conn || !isAntigravityQuotaProvider(conn.provider ?? null)) return null; + + const psd = asRecord(conn.providerSpecificData); + const untils = asRecord(psd[FAMILY_PSD_KEY]); + const existingMs = parseUntilMs(untils[family]); + const nextMs = parseUntilMs(params.rateLimitedUntil); + if (!Number.isFinite(nextMs)) return psd; + if (Number.isFinite(existingMs) && existingMs > Date.now() && existingMs >= nextMs) { + return psd; + } + + const nextPsd: JsonRecord = { + ...psd, + [FAMILY_PSD_KEY]: { ...untils, [family]: params.rateLimitedUntil }, + }; + await updateProviderConnection(params.connectionId, { providerSpecificData: nextPsd }); + return nextPsd; +} + +/** Fire-and-forget family PSD write. RPM/burst 429s must pass reason !== quota_exhausted. */ +export function persistAntigravityFamilyCooldownIfQuota(params: { + provider?: string | null; + connectionId: string; + model?: string | null; + cooldownMs: number; + reason?: string | null; +}): void { + if (!isAntigravityQuotaProvider(params.provider)) return; + if (!params.model?.trim() || params.cooldownMs <= 0) return; + if (params.reason != null && params.reason !== "quota_exhausted") return; + void persistAntigravityFamilyCooldown({ + connectionId: params.connectionId, + model: params.model, + rateLimitedUntil: new Date(Date.now() + params.cooldownMs).toISOString(), + }).catch(() => {}); +} + +export async function persistAntigravityPreflightFamilyLock(params: { + provider: string; + connectionId: string; + model: string; + unavailableUntil: string; +}): Promise { + const cooldownMs = Math.max(0, Date.parse(params.unavailableUntil) - Date.now()); + lockAntigravityFamilyModel(params.connectionId, params.model, "quota_exhausted", cooldownMs); + await persistAntigravityFamilyCooldown({ + connectionId: params.connectionId, + model: params.model, + rateLimitedUntil: params.unavailableUntil, + }); +} + +export function rehydrateAntigravityFamilyLocks( + provider: string, + connectionId: string, + providerSpecificData: JsonRecord | null | undefined +): void { + if (!isAntigravityQuotaProvider(provider)) return; + const untils = asRecord(asRecord(providerSpecificData)[FAMILY_PSD_KEY]); + const now = Date.now(); + for (const family of ["gemini", "claude"] as const) { + const untilMs = parseUntilMs(untils[family]); + if (!Number.isFinite(untilMs) || untilMs <= now) continue; + const model = dummyModelForFamily(family); + const remainingMs = untilMs - now; + lockAntigravityFamilyModel(connectionId, model, "quota_exhausted", remainingMs); + } +} + +export function rehydrateAntigravityFamilyLocksForConnections( + provider: string, + connections: Array<{ id: string; providerSpecificData?: unknown }> +): void { + if (!isAntigravityQuotaProvider(provider)) return; + for (const conn of connections) { + rehydrateAntigravityFamilyLocks( + provider, + conn.id, + conn.providerSpecificData as JsonRecord | null | undefined + ); + } +} + +/** Family lock for executor quota exhaustion. Returns false when model is absent. */ +export function markAntigravityModelQuotaExhausted( + connectionId: string, + retryAfterMs: number, + model?: string | null +): boolean { + if (!model) return false; + lockAntigravityFamilyModel(connectionId, model, "quota_exhausted", retryAfterMs); + persistAntigravityFamilyCooldownIfQuota({ + provider: "agy", + connectionId, + model, + cooldownMs: retryAfterMs, + reason: "quota_exhausted", + }); + return true; +} diff --git a/open-sse/services/antigravityQuotaFamily.ts b/open-sse/services/antigravityQuotaFamily.ts index e9ede18749..94016c1109 100644 --- a/open-sse/services/antigravityQuotaFamily.ts +++ b/open-sse/services/antigravityQuotaFamily.ts @@ -54,3 +54,77 @@ export function getQuotaScopeLabelForProvider( if (provider !== "antigravity" && provider !== "agy") return "model"; return getAntigravityQuotaFamily(model) === "other" ? "model" : "family"; } + +export function isAntigravityQuotaProvider(provider: string | null | undefined): boolean { + return provider === "antigravity" || provider === "agy"; +} + +export function quotaWindowNamesForScope( + names: string[], + scope?: { provider?: string | null; requestedModel?: string | null } +): string[] { + if (!scope?.requestedModel || !isAntigravityQuotaProvider(scope.provider)) return names; + const scoped = selectAntigravityQuotaWindowNames(names, scope.requestedModel); + return scoped.length > 0 ? scoped : names; +} + +/** Min remaining % across scoped windows, or 100 when an Antigravity family scope matched none. */ +export function remainingPercentFromQuotaWindows( + rawWindows: Record, + scope?: { provider?: string | null; requestedModel?: string | null } +): number | null { + const names = Object.keys(rawWindows); + const namesToScan = quotaWindowNamesForScope(names, scope); + let minRemaining: number | null = null; + for (const name of namesToScan) { + const windowInfo = rawWindows[name]; + if (!windowInfo || typeof windowInfo !== "object") continue; + const percentUsed = Number((windowInfo as Record).percentUsed); + if (!Number.isFinite(percentUsed)) continue; + const remaining = Math.max(0, Math.min(100, (1 - percentUsed) * 100)); + minRemaining = minRemaining === null ? remaining : Math.min(minRemaining, remaining); + } + if (minRemaining !== null) return minRemaining; + if (scope?.requestedModel && namesToScan !== names) return 100; + return null; +} + +/** + * Windows that belong to the requested Antigravity family. Claude weekly must + * not ride along on a Gemini request (and the reverse). + */ +export function selectAntigravityQuotaWindowNames( + quotaNames: string[], + requestedModel: string | null | undefined +): string[] { + if (!requestedModel) return quotaNames; + const requestedFamily = getAntigravityQuotaFamily(requestedModel); + const cleanRequestedModel = requestedModel.replace(/^(antigravity|agy)\//, ""); + const bareModel = cleanRequestedModel.includes("/") + ? cleanRequestedModel.slice(cleanRequestedModel.lastIndexOf("/") + 1) + : cleanRequestedModel; + + if (requestedFamily === "other") { + return quotaNames.filter((windowName) => { + const bare = windowName.replace(/^(antigravity|agy)\//, ""); + return bare === bareModel || bare === cleanRequestedModel; + }); + } + + const familyAggregates = + requestedFamily === "gemini" + ? ["gemini_weekly"] + : requestedFamily === "claude" + ? ["claude_gpt_weekly"] + : []; + + const exactWindows = quotaNames.filter((windowName) => { + const bare = windowName.replace(/^(antigravity|agy)\//, ""); + return bare === bareModel; + }); + const aggregateWindows = familyAggregates.filter((key) => quotaNames.includes(key)); + const scoped = [...exactWindows, ...aggregateWindows]; + if (scoped.length > 0) return scoped; + + return quotaNames.filter((windowName) => getAntigravityQuotaFamily(windowName) === requestedFamily); +} diff --git a/open-sse/services/autoCombo/__tests__/scoresAs-11489.test.ts b/open-sse/services/autoCombo/__tests__/scoresAs-11489.test.ts index b1d8d31b36..b3f719558f 100644 --- a/open-sse/services/autoCombo/__tests__/scoresAs-11489.test.ts +++ b/open-sse/services/autoCombo/__tests__/scoresAs-11489.test.ts @@ -59,11 +59,9 @@ describe("#11489 resolveScoresAs", () => { expect(resolveScoresAs("claude-sonnet-5")).toEqual({ base: "claude-sonnet-5", via: null }); }); - it("resolves the cursor/agy spelling of a Claude model to its canonical id", () => { - // `claude-4.6-opus-high` strips to `claude-4.6-opus`, which is not a catalog - // id — the canonical spelling is `claude-opus-4-6`. Explicit registry data. - expect(resolveScoresAs("claude-4.6-opus-high")).toEqual({ - base: "claude-opus-4-6", + it("resolves curated Cursor Claude variants to their canonical ids", () => { + expect(resolveScoresAs("claude-fable-5-1-thinking-high")).toEqual({ + base: "claude-fable-5-1", via: "explicit", }); expect(resolveScoresAs("claude-4.6-sonnet-medium")).toEqual({ diff --git a/open-sse/services/browserBackedChat.ts b/open-sse/services/browserBackedChat.ts index 4b3c7078e7..fa51a9c266 100644 --- a/open-sse/services/browserBackedChat.ts +++ b/open-sse/services/browserBackedChat.ts @@ -236,6 +236,7 @@ export async function browserBackedChat( userAgent, locale, timezone, + headless, inputSelector, submitButtonSelector, submitButtonMode = "playwright", @@ -257,11 +258,13 @@ export async function browserBackedChat( userAgent, locale, timezone, + headless, }); const acquireContextMs = Date.now() - tAcquireStart; const page = await openPage(pooled); const observedPostUrls: string[] = []; + const observedPostResponses: Array<{ url: string; status: number }> = []; page.on("request", (request) => { if (request.method() !== "POST") return; try { @@ -273,6 +276,19 @@ export async function browserBackedChat( // Ignore malformed/non-HTTP request URLs. } }); + page.on("response", (response) => { + if (response.request().method() !== "POST") return; + try { + const url = new URL(response.url()); + if (!url.hostname.endsWith(chatUrlMatchDomain)) return; + observedPostResponses.push({ + url: `${url.origin}${url.pathname}`, + status: response.status(), + }); + } catch { + // Ignore malformed/non-HTTP response URLs. + } + }); try { const tNavStart = Date.now(); await withAbort( @@ -379,6 +395,7 @@ export async function browserBackedChat( body, isStealth: pooled.isStealth, observedPostUrls, + observedPostResponses, timing: { acquireContextMs, navigateMs, @@ -404,6 +421,7 @@ export async function browserBackedChat( body, isStealth: pooled.isStealth, observedPostUrls, + observedPostResponses, timing: { acquireContextMs, navigateMs: 0, diff --git a/open-sse/services/browserBackedChat/types.ts b/open-sse/services/browserBackedChat/types.ts index c90fb3b80e..3a197be16c 100644 --- a/open-sse/services/browserBackedChat/types.ts +++ b/open-sse/services/browserBackedChat/types.ts @@ -29,6 +29,8 @@ export interface BrowserBackedChatRequest { locale?: string; /** Browser IANA timezone. Defaults to America/New_York. */ timezone?: string; + /** Launch a headed browser when the provider rejects true headless mode. */ + headless?: boolean; /** Selector for the provider chat input. */ inputSelector: string; /** Optional selector for the provider submit button. */ @@ -64,6 +66,8 @@ export interface BrowserBackedChatResult { isStealth: boolean; /** Sanitized POST targets observed while submitting. */ observedPostUrls?: string[]; + /** Sanitized POST response targets and statuses observed while submitting. */ + observedPostResponses?: Array<{ url: string; status: number }>; timing: { acquireContextMs: number; navigateMs: number; diff --git a/open-sse/services/browserPool.ts b/open-sse/services/browserPool.ts index bcab2dc174..09f1d40866 100644 --- a/open-sse/services/browserPool.ts +++ b/open-sse/services/browserPool.ts @@ -26,6 +26,8 @@ import { Buffer } from "node:buffer"; +import { connectObscuraBrowser } from "./obscura.ts"; + type Browser = import("playwright").Browser; type BrowserContext = import("playwright").BrowserContext; type Page = import("playwright").Page; @@ -86,11 +88,20 @@ function createBrowserPoolMetrics(): BrowserPoolMetrics { }; } +type PoolEngine = "obscura" | "cloakbrowser" | "chromium"; + +interface PendingContextEntry { + promise: Promise; + createdAt: number; +} + interface PoolState { browser: Browser | null; + /** Engine backing the headless browser, for metrics and stealth detection. */ + engine: PoolEngine | null; headedBrowser: Browser | null; contexts: Map; - pendingContexts: Map>; + pendingContexts: Map; launching: Promise | null; headedLaunching: Promise | null; generation: number; @@ -110,9 +121,10 @@ const DEFAULT_USER_AGENT = const state: PoolState = { browser: null, + engine: null, headedBrowser: null, contexts: new Map(), - pendingContexts: new Map(), + pendingContexts: new Map; createdAt: number }>(), launching: null, headedLaunching: null, generation: 0, @@ -175,6 +187,15 @@ function evictStaleContexts(): void { pooled.context.close().catch(() => {}); } } + // #12179: also evict pendingContexts entries that never resolved, so a hung + // launch cannot pin the map (and the pool) open forever. + const PENDING_TTL_MS = 5 * 60 * 1000; + for (const [key, pending] of state.pendingContexts) { + if (now - pending.createdAt > PENDING_TTL_MS) { + state.pendingContexts.delete(key); + state.metrics.contextsEvicted++; + } + } if ( state.contexts.size === 0 && state.pendingContexts.size === 0 && @@ -288,13 +309,26 @@ async function launchBrowserInstance( options: BrowserPoolContextOptions, headless: boolean ): Promise { + // A headed browser must be a real windowed Chromium, so the engine + // preference below applies to the headless path only. if (!headless) { const { chromium } = await import("playwright"); return chromium.launch(resolvePlainBrowserLaunchOptions(options)); } + // #12274: prefer Obscura (lightweight, browser-grade CDP) over a full + // Chromium; fall back to cloakbrowser, then plain Chromium. Obscura's + // lifecycle (one shared `obscura serve` per process) lives in ./obscura.ts, + // so executors like cloudflare-playground reuse the same server. + const obscura = await connectObscuraBrowser(); + if (obscura) { + state.engine = "obscura"; + return obscura.browser; + } + const cloakLaunch = await resolveCloakLaunch(); if (cloakLaunch) { + state.engine = "cloakbrowser"; return cloakLaunch({ headless: true, args: ["--no-sandbox", "--disable-dev-shm-usage"], @@ -303,6 +337,7 @@ async function launchBrowserInstance( // Fallback: plain Playwright. Works for Claude web (cookie-only auth) but // DDG's VQD challenge will detect this Chromium build. + state.engine = "chromium"; const { chromium } = await import("playwright"); return chromium.launch(resolvePlainBrowserLaunchOptions(options)); } @@ -464,14 +499,14 @@ export async function acquireBrowserContext( // Dedup concurrent creations for the same key const pending = state.pendingContexts.get(poolKey); - if (pending) return pending; + if (pending) return pending.promise; const createPromise = (async (): Promise => { const [browser, proxy] = await Promise.all([ launchBrowser(options), resolveBrowserContextProxy(key, options), ]); - const isStealth = headless && state.cloakLaunch !== null; + const isStealth = headless && (state.engine === "obscura" || state.cloakLaunch !== null); const context = await browser.newContext({ userAgent: options.userAgent || DEFAULT_USER_AGENT, locale: options.locale || "en-US", @@ -510,7 +545,7 @@ export async function acquireBrowserContext( return pooled; })(); - state.pendingContexts.set(poolKey, createPromise); + state.pendingContexts.set(poolKey, { promise: createPromise, createdAt: Date.now() }); createPromise .then(() => settlePendingContext(poolKey, false)) .catch(() => settlePendingContext(poolKey, true)); @@ -580,6 +615,10 @@ export async function shutdownPool(reason: string): Promise { } state.launching = null; state.headedLaunching = null; + // #12274: the shared Obscura server is owned by ./obscura.ts and reused by + // executors (cloudflare-playground), so closing the pool's CDP connection is + // enough — never kill the server here. + state.engine = null; state.lastActivity = Date.now(); // Avoid unused-parameter lint: log reason via debug if anyone hooks // process.on('exit') and prints state. @@ -590,6 +629,7 @@ export function getBrowserPoolStatus(): { enabled: boolean; contexts: number; browserRunning: boolean; + engine: PoolEngine | null; stealthAvailable: boolean; lastActivityAgoMs: number; } { @@ -597,7 +637,8 @@ export function getBrowserPoolStatus(): { enabled: isPoolEnabled(), contexts: state.contexts.size, browserRunning: state.browser !== null || state.headedBrowser !== null, - stealthAvailable: state.cloakLaunch !== null, + engine: state.engine, + stealthAvailable: state.engine === "obscura" || state.cloakLaunch !== null, lastActivityAgoMs: state.lastActivity === 0 ? -1 : Date.now() - state.lastActivity, }; } diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 9c316e52c7..be662c600f 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -613,12 +613,13 @@ export async function buildAutoCandidates( const quota = await quotaPromises.get(quotaKey)!; resetWindowAffinity = calculateResetWindowAffinity(quota, resetWindowConfig); if (!quotaCutoffBlocked) { - quotaRemaining = quotaRemainingPercentFromQuota(quota); + quotaRemaining = quotaRemainingPercentFromQuota(quota, { provider, requestedModel: modelStr }); } if (!quotaCutoffBlocked && quotaCutoffEnabled) { const cutoffDecision = evaluateQuotaCutoff( quota as QuotaInfo | null, - buildAutoQuotaThresholds(provider, connection, resilienceSettings) + buildAutoQuotaThresholds(provider, connection, resilienceSettings), + { provider, requestedModel: modelStr } ); if (!cutoffDecision.proceed) { quotaCutoffBlocked = true; @@ -1379,7 +1380,7 @@ async function handleComboChatInner({ resilienceSettings, quotaCutoffResetWindowConfig, combo.name, - log + log, modelStr ); if (quotaCutoff.blocked) { log.info( @@ -1660,8 +1661,16 @@ async function handleComboChatInner({ } } - // Universal handoff: inject existing handoff if model changed + // Universal handoff: inject existing handoff if model changed. i === 0 + // only: a fallback target (i > 0) serves the SAME client request the + // failed primary target would have served, with the original messages + // already intact -- there's nothing to hand off, since the client never + // saw the earlier target fail. Injecting a handoff note there replaces + // real context with a context-free note, which weaker fallback models + // have been observed treating as license to fabricate content instead + // of just answering the actual request (#12227 follow-up). if ( + i === 0 && universalHandoffConfig.enabled && relayOptions?.sessionId && !(body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] @@ -1927,7 +1936,14 @@ async function handleComboChatInner({ provider, target.connectionId ?? undefined ); - if (prevModel && prevModel !== modelStr) { + // i === 0 only: a same-request fallback target (i > 0) never + // needs a summary generated for it -- see the injection-site + // comment above. recordSessionModelUsage above stays + // unconditional regardless of i: it must reflect whichever + // model actually served THIS response, since the next + // request's i === 0 comparison depends on that being + // accurate even when this response came from a fallback. + if (i === 0 && prevModel && prevModel !== modelStr) { const handoffSourceMessages = Array.isArray(body?.messages) && body.messages.length > 0 ? body.messages @@ -4015,8 +4031,5 @@ async function handleRoundRobinCombo({ } log.warn("COMBO-RR", `All models failed | ${msg}`); - return new Response(JSON.stringify({ error: { message: msg } }), { - status, - headers: { "Content-Type": "application/json" }, - }); + return new Response(JSON.stringify({ error: { message: msg } }), { status, headers: { "Content-Type": "application/json" } }); } diff --git a/open-sse/services/combo/comboPredicates.ts b/open-sse/services/combo/comboPredicates.ts index 2c6acb099e..972abc5765 100644 --- a/open-sse/services/combo/comboPredicates.ts +++ b/open-sse/services/combo/comboPredicates.ts @@ -7,6 +7,7 @@ */ import { EXECUTOR_CONTRACT_VIOLATION_CODE } from "../../config/constants.ts"; +import { remainingPercentFromQuotaWindows } from "../antigravityQuotaFamily.ts"; import { errorResponse } from "../../utils/error.ts"; import { parseModel } from "../model.ts"; import { isSelfInflictedUpstreamTimeout } from "../../handlers/chatCore/cooldownClassification.ts"; @@ -431,24 +432,24 @@ export function clampPercent(value: number): number { return Math.max(0, Math.min(100, value)); } -export function quotaRemainingPercentFromQuota(quota: unknown): number { +export function quotaRemainingPercentFromQuota( + quota: unknown, + scope?: { provider?: string | null; requestedModel?: string | null } +): number { if (!quota || typeof quota !== "object") return 100; const record = quota as Record; - if (record.limitReached === true) return 0; const windows = record.windows; if (windows && typeof windows === "object" && !Array.isArray(windows)) { - let minRemaining: number | null = null; - for (const windowInfo of Object.values(windows as Record)) { - if (!windowInfo || typeof windowInfo !== "object") continue; - const percentUsed = Number((windowInfo as Record).percentUsed); - if (!Number.isFinite(percentUsed)) continue; - const remaining = clampPercent((1 - percentUsed) * 100); - minRemaining = minRemaining === null ? remaining : Math.min(minRemaining, remaining); - } - if (minRemaining !== null) return minRemaining; + const fromWindows = remainingPercentFromQuotaWindows( + windows as Record, + scope + ); + if (fromWindows !== null) return fromWindows; } + if (record.limitReached === true) return 0; + const percentUsed = Number(record.percentUsed); if (Number.isFinite(percentUsed)) return clampPercent((1 - percentUsed) * 100); return 100; diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 137561fbaa..9065e123b2 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -807,16 +807,48 @@ export function filterTargetsByRequestCompatibility( return []; } + // #12273: a sole survivor whose catalog window is known-too-small is a + // guaranteed context_length_exceeded. Restore the remaining pool so combo.ts + // can still try larger-context targets. Unknown context (`null`) is advisory + // and must not resurrect hard-rejected targets (vision / output / tools). + if ( + compatible.length === 1 && + (targetReasons.get(compatible[0]) || []).includes("context_window") + ) { + // #8332: never restore a confirmed-non-vision target onto an image request. + const restored = requirements.requiresVision + ? targets.filter((target) => !isVisionIncompatibleTarget(target, requirements)) + : targets; + if (restored.length > compatible.length) { + log.warn( + "COMBO", + `${label}: single compatible target ${compatible[0].modelStr} has known context too small for ${requirements.requiredContextTokens} token request; falling back to full pool (#12273)` + ); + return restored; + } + } + log.info( "COMBO", `${label}: kept ${compatible.length}/${targets.length} targets for request requirements` ); - log.debug?.( - "COMBO", - `${label}: rejected targets ${rejected - .map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`) - .join(", ")}` - ); + // #12273: When pool collapses significantly, log rejection reasons at info + // level so the cause is diagnosable without enabling debug logging. + if (compatible.length <= 2 && targets.length > 4) { + log.info( + "COMBO", + `${label}: rejected targets ${rejected + .map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`) + .join(", ")}` + ); + } else { + log.debug?.( + "COMBO", + `${label}: rejected targets ${rejected + .map((entry) => `${entry.target.modelStr}(${entry.reasons.join("+")})`) + .join(", ")}` + ); + } return compatible; } diff --git a/open-sse/services/combo/nativeCodexTurnPin.ts b/open-sse/services/combo/nativeCodexTurnPin.ts index 9910d8d993..2b6d94945f 100644 --- a/open-sse/services/combo/nativeCodexTurnPin.ts +++ b/open-sse/services/combo/nativeCodexTurnPin.ts @@ -245,7 +245,8 @@ export async function isPinnedTargetModelScopedUnusable(args: { resilienceSettings, quotaCutoffResetWindowConfig, comboName, - log ?? { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} } + log ?? { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + target.modelStr ); if (cutoff.blocked) return true; } diff --git a/open-sse/services/combo/quotaExhaustionCutoff.ts b/open-sse/services/combo/quotaExhaustionCutoff.ts index 2dc78dbe1b..a73d3628f4 100644 --- a/open-sse/services/combo/quotaExhaustionCutoff.ts +++ b/open-sse/services/combo/quotaExhaustionCutoff.ts @@ -97,7 +97,8 @@ export async function resolveQuotaExhaustionCutoffForTarget( resilienceSettings: ResilienceSettings | null | undefined, resetWindowConfig: ResetWindowConfig, comboName: string, - log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void } + log: { debug?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void }, + requestedModel?: string | null ): Promise<{ blocked: boolean; reason?: string }> { const quotaCutoffEnabled = (resilienceSettings ?? resolveResilienceSettings(null))?.quotaPreflight?.enabled === true; @@ -126,7 +127,8 @@ export async function resolveQuotaExhaustionCutoffForTarget( }); const cutoffDecision = evaluateQuotaCutoff( quota as QuotaInfo | null, - buildAutoQuotaThresholds(provider, connection, resilienceSettings) + buildAutoQuotaThresholds(provider, connection, resilienceSettings), + { provider, requestedModel: requestedModel ?? null } ); if (!cutoffDecision.proceed) { return { blocked: true, reason: cutoffDecision.reason || "quota_exhausted" }; diff --git a/open-sse/services/combo/validateQuality.ts b/open-sse/services/combo/validateQuality.ts index be70038994..723a10b614 100644 --- a/open-sse/services/combo/validateQuality.ts +++ b/open-sse/services/combo/validateQuality.ts @@ -16,6 +16,45 @@ import { evaluateResponseValidation, type ResponseValidationConfig } from "./res import { getReasoningTokens } from "../../../src/lib/usage/tokenAccounting.ts"; import type { ComboRetryAfter } from "./types.ts"; +/** + * Detects tool_calls entries within one assistant message that repeat the + * exact same function name + arguments verbatim -- always a bug (no + * legitimate use calls one tool twice with identical arguments in the same + * turn), and a real observed failure mode of at least one free-tier + * streaming model (minimax-m3:free via OpenRouter/GMICloud, 2026-09-02: + * duplicated a heartbeat_respond call byte-for-byte, confirmed at the raw + * SSE wire level -- an upstream bug, not an OmniRoute reconstruction + * artifact). Used two ways: to fail a non-streaming response over to a + * sibling combo target (see validateResponseQuality below), and, post- + * stream, to flag an already-relayed streaming response as an on-spec + * violation despite its clean HTTP 200 (see attemptLogging.ts's + * persistAttemptLogs) -- a streaming response can't be retried once real + * content has started reaching the client (the quality-gate peek below only + * ever validates the START of a stream, by design, to avoid buffering the + * whole response and defeating streaming's latency purpose), so flagging it + * after the fact is what's actually achievable for that path. + */ +export function findToolCallSpecViolation(responseBody: unknown): string | null { + const json = isRecord(responseBody) ? responseBody : null; + const choices = json?.choices; + const firstChoice = Array.isArray(choices) ? choices[0] : null; + const message = isRecord(firstChoice) ? firstChoice.message : null; + const toolCalls = isRecord(message) ? message.tool_calls : null; + if (!Array.isArray(toolCalls) || toolCalls.length < 2) return null; + + const seen = new Set(); + for (const call of toolCalls) { + const fn = isRecord(call) ? call.function : null; + if (!isRecord(fn) || typeof fn.name !== "string" || typeof fn.arguments !== "string") { + continue; + } + const signature = `${fn.name}\u0000${fn.arguments}`; + if (seen.has(signature)) return `duplicate tool_calls entry for "${fn.name}"`; + seen.add(signature); + } + return null; +} + export function toRetryAfterDisplayValue(value: ComboRetryAfter): string | Date { if (typeof value !== "number") return value; if (value > 0 && value < 1_000_000_000) { @@ -327,9 +366,9 @@ export async function validateResponseQuality( function isTerminalUsageOnlyChunk(parsed: Record, eventType: string): boolean { return Boolean( parsed.usage && - typeof parsed.usage === "object" && - !Array.isArray(parsed.choices) && - !eventType.startsWith("response.") + typeof parsed.usage === "object" && + !Array.isArray(parsed.choices) && + !eventType.startsWith("response.") ); } @@ -734,6 +773,11 @@ export async function validateResponseQuality( } const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0; + const specViolation = findToolCallSpecViolation(json); + if (specViolation) { + return { valid: false, reason: specViolation }; + } + if (!hasContent && !hasToolCalls) { return { valid: false, reason: "empty content and no tool_calls in response" }; } diff --git a/open-sse/services/compression/resultMemo.ts b/open-sse/services/compression/resultMemo.ts index b4c64d9112..de198d1043 100644 --- a/open-sse/services/compression/resultMemo.ts +++ b/open-sse/services/compression/resultMemo.ts @@ -149,7 +149,7 @@ export function memoLookup(key: string): CompressionResult | null { memoHits++; recordLookup(true); // Return a clone so downstream mutation cannot corrupt the cached value. - const cloned = JSON.parse(JSON.stringify(hit)) as CompressionResult; + const cloned = structuredClone(hit); if (cloned.stats) { cloned.stats.memoHit = true; } @@ -162,7 +162,7 @@ export function memoStore(key: string, result: CompressionResult): CompressionRe // Returns the stored clone so callers that need a fresh instance (the common // `memoStore(key, result); return memoLookup(key)!` idiom) can avoid a redundant // second multi-MB deep clone of the body on the way out. - const stored = JSON.parse(JSON.stringify(result)) as CompressionResult; + const stored = structuredClone(result); boundedSet(key, stored); return stored; } diff --git a/open-sse/services/contextHandoff.ts b/open-sse/services/contextHandoff.ts index bd169be606..c43b7f55c1 100644 --- a/open-sse/services/contextHandoff.ts +++ b/open-sse/services/contextHandoff.ts @@ -407,7 +407,10 @@ async function generateHandoffAsync(options: { relayConfig.relayMode ); const historyText = formatMessagesForPrompt(selectedMessages); - if (!historyText) return; + if (!historyText) { + logUniversalHandoffOutcome("unavailable", options.comboName, "empty selected-message history"); + return; + } const summaryPrompt = HANDOFF_PROMPT_TEMPLATE.replace("{HISTORY}", historyText); const summaryBody = { @@ -421,7 +424,14 @@ async function generateHandoffAsync(options: { }; const response = await options.handleSingleModel(summaryBody, summaryModel); - if (!response.ok) return; + if (!response.ok) { + logUniversalHandoffOutcome( + "unavailable", + options.comboName, + `summary model call failed: status=${response.status} model=${summaryModel}` + ); + return; + } let content = ""; try { @@ -436,7 +446,14 @@ async function generateHandoffAsync(options: { } const parsed = parseHandoffJSON(content); - if (!parsed) return; + if (!parsed) { + logUniversalHandoffOutcome( + "unparseable", + options.comboName, + `model=${summaryModel} contentPreview=${JSON.stringify(content.slice(0, 200))}` + ); + return; + } upsertHandoff({ sessionId: options.sessionId, @@ -572,7 +589,7 @@ export function buildUniversalHandoffSystemMessage( ${escapedReason} ${escapedPrev} ${escapedCurr} -A continuación se resume toda la conversacion para continuar sin perder el hilo. +No prior-session summary is available for this handoff. The input below (e.g. a tool result) is the entire context you have -- do not assume or invent details about a broader conversation you cannot see. `; } @@ -687,6 +704,23 @@ export function resetUniversalHandoffCooldowns(): void { universalHandoffCooldowns.clear(); } +// Every non-"generated" outcome across both handoff generators (this one and +// the older generateHandoffAsync above) used to be silent -- context_handoffs +// staying empty gave no signal on WHY (upstream call failing vs. malformed +// output vs. no history to summarize). Every live handoff then falls back to +// the bare no-summary note (buildUniversalHandoffSystemMessage's `!payload` +// branch / the context-relay equivalent), which is what actually reaches the +// model/user; without this log that always reads as a mystery instead of a +// traceable cause. +function logUniversalHandoffOutcome( + outcome: "unavailable" | "unparseable", + comboName: string, + detail: string +): void { + if (process.env.NODE_ENV === "test") return; + console.warn(`[universal-handoff] ${outcome} (combo=${comboName}): ${detail}`); +} + /** * Generate a universal handoff summary for any model/provider switch. */ @@ -709,7 +743,10 @@ async function generateUniversalHandoffAsync(options: { options.relayMode ); const historyText = formatMessagesForPrompt(selectedMessages); - if (!historyText) return "unavailable"; + if (!historyText) { + logUniversalHandoffOutcome("unavailable", options.comboName, "empty selected-message history"); + return "unavailable"; + } const summaryPrompt = HANDOFF_PROMPT_TEMPLATE.replace("{HISTORY}", historyText); const summaryModel = options.handoffModel || options.currModel; @@ -735,22 +772,25 @@ async function generateUniversalHandoffAsync(options: { }; const response = await options.handleSingleModel(summaryBody, summaryModel); - if (!response.ok) return "unavailable"; + if (!response.ok) { + const detail = `summary model call failed: status=${response.status} model=${summaryModel}`; + logUniversalHandoffOutcome("unavailable", options.comboName, detail); + return "unavailable"; + } let content = ""; try { - const json = (await response.clone().json()) as Record; - content = getResponseText(json); + content = getResponseText((await response.clone().json()) as Record); } catch { - try { - content = await response.clone().text(); - } catch { - content = ""; - } + content = await response.clone().text().catch(() => ""); } const parsed = parseHandoffJSON(content); - if (!parsed) return "unparseable"; + if (!parsed) { + const preview = JSON.stringify(content.slice(0, 200)); + logUniversalHandoffOutcome("unparseable", options.comboName, `model=${summaryModel} contentPreview=${preview}`); + return "unparseable"; + } upsertHandoff({ sessionId: options.sessionId, diff --git a/open-sse/services/conversationTurnContent.ts b/open-sse/services/conversationTurnContent.ts index a95a39c939..bc5f3bdcef 100644 --- a/open-sse/services/conversationTurnContent.ts +++ b/open-sse/services/conversationTurnContent.ts @@ -23,6 +23,90 @@ export type TurnDisplayContent = { toolName: string | null; }; +type CanonicalTurnLike = { + role: "system" | "user" | "assistant" | "tool"; + text: string; + blockKind: "text" | "tool_use" | "tool_result"; + toolName: string | null; +}; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" ? (value as JsonRecord) : null; +} + +function turnsFromBody(body: unknown): CanonicalTurnLike[] { + const rec = asRecord(body); + return rec ? extractCanonicalTurns(rec) : []; +} + +/** + * extractCanonicalTurns's Chat Completions branch only reads a message's + * `content` -- a tool-calling assistant message carries its call in + * `tool_calls` instead with `content: null`, so it silently produces no turn + * at all and the matching conversation_turn_nodes row can never resolve. + * Deliberately scoped to this read-only display path instead of extending + * extractCanonicalTurns itself: that function also drives + * conversationTracker.ts's write-path identity/hashing, and this codebase's + * only caller of it there (chat.ts's resolveConversationId) always feeds the + * client-facing Responses-API body -- never Chat Completions + * `messages`/`tool_calls` -- so extending it there would be unreachable for + * real traffic here but still carries real write-path identity-hash risk for + * any other caller/format that function might ever serve. Mirrors + * extractCanonicalTurns's own Responses-shape function_call handling: one + * turn per call, role "tool" (matches how a Responses API function_call item, + * which also carries no `role`, canonicalizes -- not "assistant"), toolName + * from the call, text the raw arguments string untouched (already a JSON + * string in both APIs, so passing it through unmodified is what a + * byte-identical hash against the original Responses-shaped item needs). + */ +function extractChatCompletionsToolUseTurns(messages: unknown): CanonicalTurnLike[] { + if (!Array.isArray(messages)) return []; + const turns: CanonicalTurnLike[] = []; + for (const item of messages) { + const rec = asRecord(item) ?? {}; + if (rec.role !== "assistant" || !Array.isArray(rec.tool_calls)) continue; + for (const call of rec.tool_calls) { + const fn = asRecord(asRecord(call)?.function); + const args = fn?.arguments; + if (typeof args !== "string" || !args) continue; + turns.push({ + role: "tool", + text: args, + blockKind: "tool_use", + toolName: typeof fn?.name === "string" ? fn.name : null, + }); + } + } + return turns; +} + +function turnsFromClientResponse(clientResponse: unknown): CanonicalTurnLike[] { + const rec = asRecord(clientResponse); + if (!rec) return []; + const summary = asRecord(rec.summary); + const output = Array.isArray(rec.output) ? rec.output : summary?.output; + return Array.isArray(output) ? extractCanonicalTurns({ input: output }) : []; +} + +function turnsFromProviderRequest(body: unknown): CanonicalTurnLike[] { + const rec = asRecord(body); + return [...turnsFromBody(rec), ...extractChatCompletionsToolUseTurns(rec?.messages)]; +} + +function indexTurns(result: Map, turns: CanonicalTurnLike[]): void { + for (const turn of turns) { + const hash = hashTurnContent(turn); + if (result.has(hash)) continue; + result.set(hash, { + textPreview: turn.text, + blockKind: turn.blockKind, + toolName: turn.toolName, + }); + } +} + /** * 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 @@ -64,19 +148,10 @@ export function resolveTurnDisplayContent( 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, - }); - } + const pipeline = asRecord(artifact?.pipeline); + indexTurns(result, turnsFromBody(asRecord(pipeline?.clientRawRequest)?.body)); + indexTurns(result, turnsFromClientResponse(pipeline?.clientResponse)); + indexTurns(result, turnsFromProviderRequest(asRecord(pipeline?.providerRequest)?.body)); } return result; } diff --git a/open-sse/services/dailyQuotaReset.ts b/open-sse/services/dailyQuotaReset.ts new file mode 100644 index 0000000000..a5108d132f --- /dev/null +++ b/open-sse/services/dailyQuotaReset.ts @@ -0,0 +1,145 @@ +/** + * Node-level daily quota reset clock. + * + * TPD cooldown endpoint: operator-configured IANA timezone + local hour. + * No default timezone. Do not call getMsUntilTomorrow() from here. + */ + +export function isValidIanaTimeZone(tz: string): boolean { + if (typeof tz !== "string" || tz.trim() === "") return false; + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz.trim() }).format(); + return true; + } catch { + return false; + } +} + +export function isValidResetHour(hour: unknown): hour is number { + return typeof hour === "number" && Number.isInteger(hour) && hour >= 0 && hour <= 23; +} + +export function nodeDailyResetConfigured(timezone: unknown, hour: unknown): boolean { + return typeof timezone === "string" && isValidIanaTimeZone(timezone) && isValidResetHour(hour); +} + +type ZonedParts = { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +}; + +function zonedParts(ms: number, timeZone: string): ZonedParts { + const fmt = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + const bag: Record = {}; + for (const part of fmt.formatToParts(new Date(ms))) { + if (part.type !== "literal") bag[part.type] = part.value; + } + return { + year: Number(bag.year), + month: Number(bag.month), + day: Number(bag.day), + hour: Number(bag.hour), + minute: Number(bag.minute), + second: Number(bag.second), + }; +} + +function addCalendarDay(year: number, month: number, day: number): { + year: number; + month: number; + day: number; +} { + const utc = Date.UTC(year, month - 1, day + 1); + const dt = new Date(utc); + return { year: dt.getUTCFullYear(), month: dt.getUTCMonth() + 1, day: dt.getUTCDate() }; +} + +/** Convert wall-clock time in `timeZone` to epoch ms. */ +function zonedLocalToUtc( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + timeZone: string, +): number { + const wanted = Date.UTC(year, month - 1, day, hour, minute, second); + let guess = wanted; + for (let i = 0; i < 4; i++) { + const p = zonedParts(guess, timeZone); + const asIfUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second); + const delta = asIfUtc - wanted; + if (delta === 0) return guess; + guess -= delta; + } + return guess; +} + +/** + * Next local `hour:00:00` in `timezone` strictly after `nowMs`. + * If now lands exactly on that instant, return the following cycle. + */ +export function nextDailyResetAtMs(timezone: string, hour: number, nowMs: number): number { + const now = zonedParts(nowMs, timezone); + let date = { year: now.year, month: now.month, day: now.day }; + let next = zonedLocalToUtc(date.year, date.month, date.day, hour, 0, 0, timezone); + if (next <= nowMs) { + date = addCalendarDay(date.year, date.month, date.day); + next = zonedLocalToUtc(date.year, date.month, date.day, hour, 0, 0, timezone); + } + return next; +} + +export function parseTpdLimitFromText(text: string): number | null { + const m = /limit:\s*(\d+)/i.exec(text); + if (!m) return null; + const n = Number(m[1]); + return Number.isFinite(n) ? n : null; +} + +export function isTpdRateLimit(errorText: string | null | undefined): boolean { + return String(errorText || "") + .toLowerCase() + .includes("tpd rate limit"); +} + +export type TpdCooldownOptions = { + timezone?: unknown; + hour?: unknown; + nowMs?: number; + headerResetAtMs?: number | null; +}; + +/** + * Cooldown for a TPD 429. Header reset wins; else the node clock. + * Both missing → null (caller uses short 429, does not guess midnight). + */ +export function resolveTpdCooldownMs( + errorText: string | null | undefined, + options: TpdCooldownOptions = {}, +): number | null { + if (!isTpdRateLimit(errorText)) return null; + const now = options.nowMs ?? Date.now(); + if (typeof options.headerResetAtMs === "number" && options.headerResetAtMs > now) { + return options.headerResetAtMs - now; + } + if (typeof options.timezone === "string" && isValidResetHour(options.hour)) { + if (!nodeDailyResetConfigured(options.timezone, options.hour)) return null; + return nextDailyResetAtMs(options.timezone, options.hour, now) - now; + } + return null; +} diff --git a/open-sse/services/genericQuotaFetcher.ts b/open-sse/services/genericQuotaFetcher.ts index 10d58d4b37..81419d5a6b 100644 --- a/open-sse/services/genericQuotaFetcher.ts +++ b/open-sse/services/genericQuotaFetcher.ts @@ -25,10 +25,53 @@ import { type QuotaInfo, } from "./quotaPreflight.ts"; +type UsageFetcher = ( + connection: Parameters[0], + options?: { forceRefresh?: boolean } +) => Promise; + +let usageFetcherOverride: UsageFetcher | null = null; + // 60s — matches Codex's TTL. Long enough to avoid hammering upstream usage // endpoints on every routing decision, short enough that a near-exhausted // account is skipped within one minute of crossing its threshold. const CACHE_TTL_MS = 60_000; +/** Drop unused force-refresh flags once inner provider caches (60s–5min) have expired. */ +const PENDING_FORCE_REFRESH_TTL_MS = CACHE_TTL_MS * 5; +/** key → Date.now() when invalidate asked the next fetch to force-refresh. */ +const pendingForceRefresh = new Map(); +/** key → last convert-null / throw while force-refresh was pending. */ +const pendingForceRefreshMiss = new Map(); + +/** Test-only: inject the usage dispatcher; pass null to restore. */ +export function __setGenericUsageFetcherForTests(fetcher: UsageFetcher | null): void { + usageFetcherOverride = fetcher; +} + +/** Test-only: backdate a pending force-refresh so TTL expiry is unit-testable. */ +export function __agePendingForceRefreshForTests( + provider: string, + connectionId: string, + ageMs: number +): void { + pendingForceRefresh.set(cacheKey(provider, connectionId), Date.now() - ageMs); +} + +/** Test-only: backdate a convert-null miss so the 60s hammer-guard is unit-testable. */ +export function __agePendingForceRefreshMissForTests( + provider: string, + connectionId: string, + ageMs: number +): void { + pendingForceRefreshMiss.set(cacheKey(provider, connectionId), Date.now() - ageMs); +} + +/** Test-only: drop all wrapper/flag maps so tests cannot leak across ids. */ +export function __resetGenericQuotaFetcherForTests(): void { + cache.clear(); + pendingForceRefresh.clear(); + pendingForceRefreshMiss.clear(); +} interface CacheEntry { quota: QuotaInfo; @@ -38,15 +81,72 @@ interface CacheEntry { const cache = new Map(); function cacheKey(provider: string, connectionId: string): string { - return `${provider}::${connectionId}`; + return `${provider.trim()}::${connectionId.trim()}`; } -// Auto-cleanup stale entries — same shape as codexQuotaFetcher. +function dropExpiredPendingForceRefresh(key: string, now: number): boolean { + const stampedAt = pendingForceRefresh.get(key); + if (stampedAt === undefined) return true; + if (now - stampedAt > PENDING_FORCE_REFRESH_TTL_MS) { + pendingForceRefresh.delete(key); + pendingForceRefreshMiss.delete(key); + return true; + } + return false; +} + +// Lazy expiry on read — same as the provider breaker. Name stays `is*` because +// callers only need a boolean; the map is not a public API. +function isPendingForceRefresh(key: string, now: number = Date.now()): boolean { + if (dropExpiredPendingForceRefresh(key, now)) return false; + return pendingForceRefresh.has(key); +} + +function markPendingForceRefreshMiss(key: string): void { + if (isPendingForceRefresh(key)) pendingForceRefreshMiss.set(key, Date.now()); +} + +function cachedQuotaIfFresh( + key: string, + forceRefresh: boolean, + now: number +): QuotaInfo | null { + if (forceRefresh) return null; + const cached = cache.get(key); + if (cached && now - cached.fetchedAt < CACHE_TTL_MS) return cached.quota; + return null; +} + +function isForceRefreshMissCooling( + key: string, + forceRefresh: boolean, + now: number +): boolean { + if (!forceRefresh) return false; + const missedAt = pendingForceRefreshMiss.get(key); + return missedAt !== undefined && now - missedAt < CACHE_TTL_MS; +} + +/** True when a concurrent 429 re-stamped a still-live flag during fetchUsage. */ +function isConcurrentForceRefresh(key: string, refreshStamp: number | undefined): boolean { + const currentStamp = pendingForceRefresh.get(key); + if (currentStamp === refreshStamp) return false; + return ( + currentStamp !== undefined && + Date.now() - currentStamp <= PENDING_FORCE_REFRESH_TTL_MS + ); +} + +// 5min — same as Codex. Expiry is lazy on read (`isPendingForceRefresh`); +// this timer only reaps keys nobody fetches after the 5min TTL. const _cacheCleanup = setInterval(() => { const now = Date.now(); for (const [key, entry] of cache) { if (now - entry.fetchedAt > CACHE_TTL_MS * 5) cache.delete(key); } + for (const key of pendingForceRefresh.keys()) { + dropExpiredPendingForceRefresh(key, now); + } }, 5 * 60_000); if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) { (_cacheCleanup as { unref?: () => void }).unref?.(); @@ -217,24 +317,47 @@ function normalizeQuotaWindows( export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) => { if (!connection) return null; const conn = connection as ConnectionInputs; - const provider = typeof conn.provider === "string" ? conn.provider : null; + const provider = typeof conn.provider === "string" ? conn.provider.trim() : ""; if (!provider) return null; const key = cacheKey(provider, connectionId); - const cached = cache.get(key); - if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { - return cached.quota; - } + const now = Date.now(); + const forceRefresh = isPendingForceRefresh(key, now); + const hit = cachedQuotaIfFresh(key, forceRefresh, now); + if (hit) return hit; + // convert-null / throw keep the force-refresh flag (agy inner caches are + // still stale) but must not hammer those endpoints on every routing tick. + if (isForceRefreshMissCooling(key, forceRefresh, now)) return null; + + // Capture before await: a 429 during fetchUsage re-stamps this; writing + // the pre-429 snapshot would wipe that flag and recache stale quota. + const refreshStamp = pendingForceRefresh.get(key); let usage: unknown; try { - usage = await getUsageForProvider(conn as Parameters[0]); + const fetchUsage = usageFetcherOverride ?? getUsageForProvider; + usage = await fetchUsage(conn as Parameters[0], { + ...(forceRefresh ? { forceRefresh: true } : {}), + }); } catch { + markPendingForceRefreshMiss(key); return null; } const quota = convertUsageToQuotaInfo(usage); - if (!quota) return null; + if (!quota) { + markPendingForceRefreshMiss(key); + return null; + } + + // Concurrent 429 re-stamped a still-live flag — do not recache the + // pre-429 snapshot. A vanished or expired stamp is not a 429. + if (isConcurrentForceRefresh(key, refreshStamp)) { + return quota; + } + + pendingForceRefresh.delete(key); + pendingForceRefreshMiss.delete(key); // Refresh the static window catalog so the dashboard can render the right // modal inputs without waiting for the user to open the page. @@ -250,7 +373,33 @@ export const fetchGenericQuota: QuotaFetcher = async (connectionId, connection) * fresh data instead of a 60s stale window. */ export function invalidateGenericQuotaCache(provider: string, connectionId: string): void { - cache.delete(cacheKey(provider, connectionId)); + const key = cacheKey(provider, connectionId); + cache.delete(key); + // Next fetch must bypass provider-inner usage caches (agy retrieveUserQuota / + // weekly are 60s–5min). Without this, dropping the 60s wrapper recaches stale. + // TTL matches those inner caches: after 5min the flag is a no-op. + pendingForceRefresh.set(key, Date.now()); + pendingForceRefreshMiss.delete(key); +} + +/** + * Drop the generic quota cache after an upstream 429, matching Codex's + * `invalidateCodexQuotaCache` on 429. Probe-origin failures must not mutate + * routing caches (#9817). + */ +export function invalidateGenericQuotaCacheOnStatus(args: { + provider: string | null | undefined; + connectionId: string | null | undefined; + status: number; + isolateProbe?: boolean; +}): boolean { + if (args.isolateProbe === true) return false; // undefined from callers that omit isolateProbe must still invalidate + if (args.status !== 429) return false; + const provider = typeof args.provider === "string" ? args.provider.trim() : ""; + const connectionId = typeof args.connectionId === "string" ? args.connectionId.trim() : ""; + if (!provider || !connectionId) return false; + invalidateGenericQuotaCache(provider, connectionId); + return true; } /** diff --git a/open-sse/services/gigachatAuth.ts b/open-sse/services/gigachatAuth.ts index 1696acc0e7..8b79d9cf63 100644 --- a/open-sse/services/gigachatAuth.ts +++ b/open-sse/services/gigachatAuth.ts @@ -15,6 +15,22 @@ type GigachatTokenOptions = { const DEFAULT_GIGACHAT_AUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"; const DEFAULT_GIGACHAT_SCOPE = "GIGACHAT_API_PERS"; const CACHE_SKEW_MS = 60_000; +const TOKEN_CACHE_MAX = 100; +const INFLIGHT_MAX = 50; + +function evictOldest(cache: Map): void { + if (cache.size >= TOKEN_CACHE_MAX) { + const first = cache.keys().next().value; + if (first) cache.delete(first); + } +} + +function evictOldestInflight(cache: Map>): void { + if (cache.size >= INFLIGHT_MAX) { + const first = cache.keys().next().value; + if (first) cache.delete(first); + } +} const tokenCache = new Map(); const inflightRequests = new Map>(); @@ -23,10 +39,12 @@ function getCacheKey(credentials: string, authUrl: string, scope: string) { return `${authUrl}::${scope}::${credentials}`; } -function isFreshToken(token: GigachatTokenResult | undefined) { +function isFreshToken(token: GigachatTokenResult | undefined, key?: string) { if (!token?.accessToken || !token?.expiresAt) return false; const expiresAtMs = new Date(token.expiresAt).getTime(); - return Number.isFinite(expiresAtMs) && expiresAtMs - Date.now() > CACHE_SKEW_MS; + const fresh = Number.isFinite(expiresAtMs) && expiresAtMs - Date.now() > CACHE_SKEW_MS; + if (!fresh && key) tokenCache.delete(key); + return fresh; } function normalizeExpiry(rawExpiry: unknown) { @@ -59,7 +77,7 @@ export async function getGigachatAccessToken( const cacheKey = getCacheKey(credentials, authUrl, scope); const cached = tokenCache.get(cacheKey); - if (isFreshToken(cached)) { + if (isFreshToken(cached, cacheKey)) { return cached; } @@ -100,10 +118,12 @@ export async function getGigachatAccessToken( accessToken, expiresAt: normalizeExpiry(data.exp ?? data.expires_at), }; + evictOldest(tokenCache); tokenCache.set(cacheKey, token); return token; })(); + evictOldestInflight(inflightRequests); inflightRequests.set(cacheKey, requestPromise); try { return await requestPromise; diff --git a/open-sse/services/modelFamilyFallback.ts b/open-sse/services/modelFamilyFallback.ts index 5e80fe0d40..6226c1217b 100644 --- a/open-sse/services/modelFamilyFallback.ts +++ b/open-sse/services/modelFamilyFallback.ts @@ -78,8 +78,9 @@ const FAMILY_FALLBACK_TEMPLATES: Record = { "gemini-2.5-pro": ["gemini-2.5-pro-preview-06-05", "gemini-2.5-pro-exp-03-25"], "gemini-2.5-pro-preview-06-05": ["gemini-2.5-pro", "gemini-2.5-pro-exp-03-25"], - // Claude Mythos family (Fable 5) — flagship falls to the next-best Opus - // tiers before the cheaper Sonnet, matching the Opus family ordering. + // Claude Mythos family — prefer the previous Fable before falling to Opus + // tiers and then the cheaper Sonnet, matching the flagship ordering. + "claude-fable-5-1": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"], "claude-fable-5": ["claude-opus-4-8", "claude-opus-4-7", "claude-sonnet-5"], // Claude Opus family diff --git a/open-sse/services/moonshotQuotaFetcher.ts b/open-sse/services/moonshotQuotaFetcher.ts new file mode 100644 index 0000000000..df80fe11b3 --- /dev/null +++ b/open-sse/services/moonshotQuotaFetcher.ts @@ -0,0 +1,229 @@ +/** + * moonshotQuotaFetcher.ts — Moonshot Open Platform balance quota fetcher + * + * GET {origin}/v1/users/me/balance + * { code: 0, data: { available_balance, voucher_balance, cash_balance } } + * + * Origin comes from the connection baseUrl (api.moonshot.cn or api.moonshot.ai). + * Do not hardcode .ai as a fallback for .cn keys. + * + * Cache: 60s in-memory. Registration: registerMoonshotQuotaFetcher() at startup. + */ + +import { toNumber } from "@/shared/utils/numeric"; +import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; +import { registerMonitorFetcher } from "./quotaMonitor.ts"; +import { throttleQuotaFetch } from "./quotaFetchThrottle.ts"; +import { + isMoonshotOpenPlatformConnection, + moonshotBalanceUrl, + resolveMoonshotOrigin, +} from "./usage/moonshotOpenPlatform.ts"; +import type { UsageQuota } from "./usage/quota.ts"; + +const CACHE_TTL_MS = 60_000; + +export interface MoonshotQuota extends QuotaInfo { + availableBalance: number; + voucherBalance: number; + cashBalance: number; + origin: string; + limitReached: boolean; +} + +interface CacheEntry { + quota: MoonshotQuota; + fetchedAt: number; +} + +const quotaCache = new Map(); + +const _cacheCleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of quotaCache) { + if (now - entry.fetchedAt > CACHE_TTL_MS * 5) { + quotaCache.delete(key); + } + } +}, 5 * 60_000); + +if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) { + (_cacheCleanup as { unref?: () => void }).unref?.(); +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function parseMoonshotQuotaResponse(data: unknown, origin: string): MoonshotQuota | null { + const obj = toRecord(data); + const code = obj.code; + if (code !== 0 && code !== undefined) return null; + const payload = toRecord(obj.data); + if (!("available_balance" in payload) && !("availableBalance" in payload)) return null; + const availableBalance = toNumber(payload.available_balance ?? payload.availableBalance, 0); + const voucherBalance = toNumber(payload.voucher_balance ?? payload.voucherBalance, 0); + const cashBalance = toNumber(payload.cash_balance ?? payload.cashBalance, 0); + const limitReached = availableBalance <= 0; + const percentUsed = limitReached ? 1 : 0; + return { + used: percentUsed * 100, + total: 100, + percentUsed, + resetAt: null, + availableBalance, + voucherBalance, + cashBalance, + origin, + limitReached, + windows: { balance: { percentUsed, resetAt: null } }, + }; +} + +function connectionApiKey(connection?: Record): string | null { + const apiKey = connection?.apiKey; + return typeof apiKey === "string" && apiKey.trim().length > 0 ? apiKey : null; +} + +export async function fetchMoonshotQuota( + connectionId: string, + connection?: Record +): Promise { + const cached = quotaCache.get(connectionId); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.quota; + } + + const apiKey = connectionApiKey(connection); + if (!apiKey) return null; + + const origin = resolveMoonshotOrigin({ + provider: typeof connection?.provider === "string" ? connection.provider : undefined, + providerSpecificData: connection?.providerSpecificData, + }); + if (!origin) return null; + + const url = moonshotBalanceUrl(origin); + const authHeader = ["Bearer", apiKey].join(" "); + + try { + await throttleQuotaFetch(); + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: authHeader, + "Content-Type": "application/json", + Accept: "application/json", + }, + signal: AbortSignal.timeout(8_000), + }); + + if (response.status === 401 || response.status === 403) { + quotaCache.delete(connectionId); + return null; + } + if (!response.ok) return null; + + const data = await response.json(); + const quota = parseMoonshotQuotaResponse(data, origin); + if (!quota) return null; + quotaCache.set(connectionId, { quota, fetchedAt: Date.now() }); + return quota; + } catch { + return null; + } +} + +export function invalidateMoonshotQuotaCache(connectionId: string): void { + quotaCache.delete(connectionId); +} + +export type MoonshotUsageConnection = { + id?: string; + provider?: string; + apiKey?: string; + providerSpecificData?: unknown; +}; + +export async function getMoonshotOpenPlatformUsage( + connection: MoonshotUsageConnection +): Promise<{ + plan?: string; + quotas?: Record; + message?: string; + limitReached?: boolean; +}> { + const origin = resolveMoonshotOrigin(connection); + if (!origin) { + return { message: "Not a Moonshot Open Platform connection." }; + } + const quota = (await fetchMoonshotQuota(connection.id || "moonshot", { + apiKey: connection.apiKey, + provider: connection.provider, + providerSpecificData: connection.providerSpecificData, + })) as MoonshotQuota | null; + if (!quota) { + return { message: "Moonshot API key not available. Add a key to view usage." }; + } + const domestic = origin.includes("moonshot.cn"); + return { + plan: domestic ? "Kimi 开放平台(国内)" : "Kimi Open Platform", + quotas: buildMoonshotBalanceQuotas(quota, domestic ? "CNY" : "USD"), + limitReached: quota.limitReached, + }; +} + +function balanceQuota( + remaining: number, + remainingPercentage: number, + currency: string +): UsageQuota { + return { + used: 0, + total: 0, + remaining, + remainingPercentage, + resetAt: null, + unlimited: true, + currency, + }; +} + +function buildMoonshotBalanceQuotas( + quota: MoonshotQuota, + currency: string +): Record { + return { + available: balanceQuota(quota.availableBalance, quota.limitReached ? 0 : 100, currency), + voucher: balanceQuota(quota.voucherBalance, 100, currency), + cash: balanceQuota(quota.cashBalance, 100, currency), + }; +} + +export function registerMoonshotQuotaFetcher(): void { + registerQuotaFetcher("moonshot", fetchMoonshotQuota); + registerQuotaFetcher("kimi", fetchMoonshotQuota); + registerMonitorFetcher("moonshot", fetchMoonshotQuota); + registerMonitorFetcher("kimi", fetchMoonshotQuota); +} + +export function registerMoonshotFetchersForNodes( + nodes: Array<{ id?: string | null; prefix?: string | null; baseUrl?: string | null }> +): void { + for (const node of nodes) { + const origin = resolveMoonshotOrigin({}, node.baseUrl); + if (!origin) continue; + if (typeof node.id === "string" && node.id) { + registerQuotaFetcher(node.id, fetchMoonshotQuota); + registerMonitorFetcher(node.id, fetchMoonshotQuota); + } + if (typeof node.prefix === "string" && node.prefix) { + registerQuotaFetcher(node.prefix, fetchMoonshotQuota); + registerMonitorFetcher(node.prefix, fetchMoonshotQuota); + } + } +} + +export { isMoonshotOpenPlatformConnection }; diff --git a/open-sse/services/obscura.ts b/open-sse/services/obscura.ts new file mode 100644 index 0000000000..23c4917214 --- /dev/null +++ b/open-sse/services/obscura.ts @@ -0,0 +1,163 @@ +/** + * obscura.ts — Shared Obscura browser engine (#12274). + * + * Obscura (https://github.com/h4ckf0r0day/obscura) is a lightweight Rust + * headless browser (~30MB resident) that speaks the Chrome DevTools Protocol. + * Playwright's `chromium.connectOverCDP` drives it like a real Chrome, so the + * browser pool and the cloudflare-playground executor can both use it without + * holding a 150-400MB Chromium process. + * + * Lifecycle: one Obscura `serve` process is spawned lazily on first use and + * shared for the server's lifetime. Callers receive a fresh CDP connection on + * demand; closing the connection does not stop the shared server. Set + * OBSCURA_CDP_ENDPOINT to point at an already-running Obscura instead of + * spawning one here (the process is then not owned by this module). The + * module is also disabled entirely when OMNIROUTE_BROWSER_POOL=off. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:net"; + +export interface ObscuraConnection { + /** Playwright Browser connected over CDP to the shared Obscura server. */ + browser: import("playwright").Browser; + /** The spawned `obscura serve` process, or null when an external endpoint is used. */ + child: ChildProcess | null; +} + +let shared: { child: ChildProcess | null; endpoint: string } | null = null; +let starting: Promise<{ child: ChildProcess | null; endpoint: string } | null> | null = null; + +export function isObscuraUsable(): boolean { + const flag = process.env.OMNIROUTE_BROWSER_POOL; + if (flag === undefined) return true; + return flag !== "off" && flag !== "0" && flag !== "false"; +} + +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => { + const address = srv.address(); + srv.close(() => { + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("obscura: could not allocate a free port")); + }); + }); + }); +} + +async function obscuraBinaryPath(): Promise { + const bin = process.env.OBSCURA_BIN; + if (bin) return bin; + const { resolve } = await import("node:path"); + const { existsSync, accessSync, constants } = await import("node:fs"); + const dirs = (process.env.PATH || "").split(":"); + for (const dir of dirs) { + const candidate = resolve(dir, "obscura"); + try { + accessSync(candidate, constants.X_OK); + if (existsSync(candidate)) return candidate; + } catch { + /* not executable here — keep looking */ + } + } + return null; +} + +async function waitForCdpEndpoint(endpoint: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1500); + // Probe /json/version, not the base URL: Obscura's HTTP server answers + // the CDP info route, while a bare GET to "/" never completes a response. + const probe = endpoint.replace(/^ws/, "http").replace(/\/$/, "") + "/json/version"; + const res = await fetch(probe, { signal: controller.signal }); + clearTimeout(timer); + if (res.ok) return true; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 250)); + } + return false; +} + +/** Ensure the shared Obscura server is up; returns its endpoint or null. */ +export async function ensureObscuraServer(): Promise<{ + child: ChildProcess | null; + endpoint: string; +} | null> { + if (!isObscuraUsable()) return null; + if (shared) return shared; + if (starting) return starting; + starting = (async () => { + const endpoint = process.env.OBSCURA_CDP_ENDPOINT; + if (endpoint) { + shared = { child: null, endpoint }; + return shared; + } + const bin = await obscuraBinaryPath(); + if (!bin) return null; + const port = Number(process.env.OBSCURA_PORT) || (await findFreePort()); + const child = spawn(bin, ["serve", "--port", String(port), "--host", "127.0.0.1"], { + stdio: ["ignore", "ignore", "pipe"], + }); + child.stderr?.on("data", () => {}); // obscura logs verbosely — swallow + const endpointForServer = `http://127.0.0.1:${port}`; + // A bad binary path (or a binary that cannot serve) must not hold the + // readiness wait for the full timeout: bail as soon as the child exits + // (or fails to spawn at all — 'exit' alone misses an ENOENT 'error'). + const died = new Promise((resolve) => { + child.once("exit", () => resolve(true)); + child.once("error", () => resolve(true)); + }); + const ready = await Promise.race([ + waitForCdpEndpoint(endpointForServer, 30_000), + died.then(() => false as const), + ]); + if (ready !== true) { + child.kill("SIGKILL"); + return null; + } + shared = { child, endpoint: endpointForServer }; + return shared; + })(); + try { + return await starting; + } finally { + starting = null; + } +} + +/** + * Connect Playwright to the shared Obscura server. Returns null when Obscura + * is disabled, not installed, or the server could not start (callers fall + * back to their previous Chromium strategy). + */ +export async function connectObscuraBrowser(): Promise { + const server = await ensureObscuraServer(); + if (!server) return null; + try { + const { chromium } = await import("playwright"); + const browser = await chromium.connectOverCDP(server.endpoint); + return { browser, child: server.child }; + } catch { + return null; + } +} + +/** Caution: this terminates the shared `obscura serve` process (process-lifetime anyway). */ +export function killSharedObscuraServer(): void { + if (shared?.child) { + try { + shared.child.kill("SIGKILL"); + } catch { + /* ignore */ + } + } + shared = null; +} diff --git a/open-sse/services/providerCostData.ts b/open-sse/services/providerCostData.ts index d9a4e0a8ab..8464635c92 100644 --- a/open-sse/services/providerCostData.ts +++ b/open-sse/services/providerCostData.ts @@ -1,4 +1,4 @@ -import type { TierAssignment } from "./tierTypes"; +import { getPricingForModel as getDefaultPricingForModel } from "@/shared/constants/pricing"; import type { TierConfig } from "./tierTypes"; export interface ModelPricing { @@ -11,6 +11,7 @@ export interface ModelPricing { export const KNOWN_MODEL_PRICING: Record = { "gpt-4o": { inputCostPer1M: 2.5, outputCostPer1M: 10.0, isFree: false }, "gpt-4o-mini": { inputCostPer1M: 0.15, outputCostPer1M: 0.6, isFree: false }, + "claude-fable-5-1": { inputCostPer1M: 10.0, outputCostPer1M: 50.0, isFree: false }, "claude-fable-5": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, "claude-opus-5": { inputCostPer1M: 5.0, outputCostPer1M: 25.0, isFree: false }, "claude-opus-4-8": { inputCostPer1M: 15.0, outputCostPer1M: 75.0, isFree: false }, @@ -37,14 +38,26 @@ export const KNOWN_MODEL_PRICING: Record = { }; export function getModelPricing(provider: string, model: string): ModelPricing { - const directKey = model.toLowerCase(); - if (KNOWN_MODEL_PRICING[directKey]) { - return KNOWN_MODEL_PRICING[directKey]; - } const providerKey = `${provider}/${model}`.toLowerCase(); if (KNOWN_MODEL_PRICING[providerKey]) { return KNOWN_MODEL_PRICING[providerKey]; } + const providerPricing = getDefaultPricingForModel(provider, model); + if (providerPricing) { + const inputCostPer1M = Number(providerPricing.input); + const outputCostPer1M = Number(providerPricing.output); + if (Number.isFinite(inputCostPer1M) && Number.isFinite(outputCostPer1M)) { + return { + inputCostPer1M, + outputCostPer1M, + isFree: inputCostPer1M === 0 && outputCostPer1M === 0, + }; + } + } + const directKey = model.toLowerCase(); + if (KNOWN_MODEL_PRICING[directKey]) { + return KNOWN_MODEL_PRICING[directKey]; + } return { inputCostPer1M: 5.0, outputCostPer1M: 15.0, isFree: false }; } diff --git a/open-sse/services/quotaPreflight.ts b/open-sse/services/quotaPreflight.ts index a6c7d99aef..e22d8597b6 100644 --- a/open-sse/services/quotaPreflight.ts +++ b/open-sse/services/quotaPreflight.ts @@ -21,12 +21,22 @@ import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId"; import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags"; import { fetchNewApiAggregatorQuota } from "./newApiAggregatorQuotaFetcher.ts"; +import { + isAntigravityQuotaProvider, + selectAntigravityQuotaWindowNames, +} from "./antigravityQuotaFamily.ts"; export interface PreflightQuotaResult { proceed: boolean; reason?: string; quotaPercent?: number; resetAt?: string | null; + windowName?: string | null; +} + +export interface QuotaCutoffScope { + provider?: string | null; + requestedModel?: string | null; } export interface QuotaWindowInfo { @@ -156,15 +166,36 @@ function isRemainingAtOrBelowThreshold( return remainingPercent <= thresholdPercent + REMAINING_PERCENT_EPSILON; } -function exhaustedResult(quotaPercent: number, resetAt: string | null): PreflightQuotaResult { +function exhaustedResult( + quotaPercent: number, + resetAt: string | null, + windowName?: string | null +): PreflightQuotaResult { return { proceed: false, reason: "quota_exhausted", quotaPercent, resetAt, + windowName: windowName ?? null, }; } +function windowsForScope( + windows: NonNullable, + scope?: QuotaCutoffScope +): NonNullable { + if (!scope?.requestedModel || !isAntigravityQuotaProvider(scope.provider ?? null)) { + return windows; + } + const selected = selectAntigravityQuotaWindowNames(Object.keys(windows), scope.requestedModel); + if (selected.length === 0) return windows; + const scoped: NonNullable = {}; + for (const name of selected) { + if (windows[name]) scoped[name] = windows[name]; + } + return Object.keys(scoped).length > 0 ? scoped : windows; +} + function limitReachedResult(quota: QuotaInfo): PreflightQuotaResult { return exhaustedResult( Number.isFinite(quota.percentUsed) ? quota.percentUsed : 1, @@ -201,7 +232,9 @@ function quotaWindowCutoffResult( worstResetAt = windowInfo.resetAt ?? null; } - return worstWindow === null ? null : exhaustedResult(worstUsedPercent, worstResetAt); + return worstWindow === null + ? null + : exhaustedResult(worstUsedPercent, worstResetAt, worstWindow); } function quotaPercentCutoffResult( @@ -227,21 +260,27 @@ function quotaPercentCutoffResult( */ export function evaluateQuotaCutoff( quota: QuotaInfo | null | undefined, - thresholds?: PreflightQuotaThresholds + thresholds?: PreflightQuotaThresholds, + scope?: QuotaCutoffScope ): PreflightQuotaResult { if (!quota) return { proceed: true }; - if (quota.limitReached === true) return limitReachedResult(quota); const windows = quota.windows; if (windows && Object.keys(windows).length > 0) { - return ( - quotaWindowCutoffResult(windows, thresholds) ?? { - proceed: true, - quotaPercent: quota.percentUsed, - } - ); + const scopedWindows = windowsForScope(windows, scope); + const cutoff = quotaWindowCutoffResult(scopedWindows, thresholds); + if (cutoff) return cutoff; + if (isAntigravityQuotaProvider(scope?.provider ?? null) && scope?.requestedModel) { + return { proceed: true, quotaPercent: quota.percentUsed }; + } + if (quota.limitReached === true) return limitReachedResult(quota); + return { + proceed: true, + quotaPercent: quota.percentUsed, + }; } + if (quota.limitReached === true) return limitReachedResult(quota); return quotaPercentCutoffResult(quota, thresholds); } @@ -297,61 +336,40 @@ export async function preflightQuota( return { proceed: true }; } - if (quota.limitReached === true) { - return limitReachedResult(quota); - } - - // Per-window evaluation — only when the fetcher surfaces a windows map. - // We block as soon as ANY single window's remaining quota drops to its - // configured cutoff or below; warnings are logged independently per window. - if (quota.windows && Object.keys(quota.windows).length > 0) { - let worstUsedPercent = 0; - let worstWindow: string | null = null; - let worstResetAt: string | null = null; - for (const [windowName, windowInfo] of Object.entries(quota.windows)) { - const minRemainingPercent = resolveOrDefault( - thresholds?.resolveMinRemainingPercent, - windowName, - DEFAULT_MIN_REMAINING_PERCENT - ); + const requestedModel = + typeof connection.requestedModel === "string" ? connection.requestedModel : null; + const scope: QuotaCutoffScope = { provider, requestedModel }; + const windows = quota.windows; + if (windows && Object.keys(windows).length > 0) { + const scopedWindows = windowsForScope(windows, scope); + for (const [windowName, windowInfo] of Object.entries(scopedWindows)) { const warnRemainingPercent = resolveOrDefault( thresholds?.resolveWarnRemainingPercent, windowName, DEFAULT_WARN_REMAINING_PERCENT ); const remainingPercent = remainingPercentFrom(windowInfo.percentUsed); - - if (isRemainingAtOrBelowThreshold(remainingPercent, minRemainingPercent)) { - // Track the most-depleted blocking window so the response can name it. - if (windowInfo.percentUsed > worstUsedPercent) { - worstUsedPercent = windowInfo.percentUsed; - worstWindow = windowName; - worstResetAt = windowInfo.resetAt ?? null; - } else if (worstWindow === null) { - worstWindow = windowName; - worstResetAt = windowInfo.resetAt ?? null; - } - } else if (isRemainingAtOrBelowThreshold(remainingPercent, warnRemainingPercent)) { + if (isRemainingAtOrBelowThreshold(remainingPercent, warnRemainingPercent)) { console.warn( `[QuotaPreflight] ${provider}/${connectionId} ${windowName}: ${remainingPercent.toFixed(1)}% remaining — approaching cutoff` ); } } + } - if (worstWindow !== null) { - const worstRemaining = remainingPercentFrom(worstUsedPercent); - console.info( - `[QuotaPreflight] ${provider}/${connectionId} ${worstWindow}: ${worstRemaining.toFixed(1)}% remaining — switching` - ); - return { - proceed: false, - reason: "quota_exhausted", - quotaPercent: worstUsedPercent, - resetAt: worstResetAt, - }; - } - - return { proceed: true, quotaPercent: quota.percentUsed }; + const decision = evaluateQuotaCutoff(quota, thresholds, scope); + if (!decision.proceed) { + const windowLabel = decision.windowName ? ` ${decision.windowName}` : ""; + const remaining = Number.isFinite(decision.quotaPercent) + ? remainingPercentFrom(decision.quotaPercent as number).toFixed(1) + : "?"; + console.info( + `[QuotaPreflight] ${provider}/${connectionId}${windowLabel}: ${remaining}% remaining - switching` + ); + return decision; + } + if (windows && Object.keys(windows).length > 0) { + return decision; } // Legacy single-signal path for fetchers that don't expose per-window data. diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts index 94cd99f934..48f1eed051 100644 --- a/open-sse/services/responsesInputSanitizer.ts +++ b/open-sse/services/responsesInputSanitizer.ts @@ -11,6 +11,10 @@ const SERVER_ITEM_ID_PREFIX_BY_TYPE: Record = { reasoning: "rs_", }; const SERVER_ITEM_ID_PATTERN = /^(fc|msg|rs|resp)_/; +// Validated per input item of type function_call / function_call_output (the agentic +// Responses path), so kept as a module constant instead of an inline literal. +const FUNCTION_NAME_VALID_RE = /^[a-zA-Z0-9_-]{1,128}$/; +const FUNCTION_NAME_SANITIZE_RE = /[^a-zA-Z0-9_-]/g; function toRecord(value: unknown): JsonRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; @@ -38,7 +42,7 @@ export function isInternalAssistantMessage(record: JsonRecord): boolean { // Sanitize after cloning so upstream never sees an invalid name. function sanitizeFunctionName(name: string): string { // Replace any character not in [a-zA-Z0-9_-] with underscore, then truncate. - return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 128); + return name.replace(FUNCTION_NAME_SANITIZE_RE, "_").slice(0, 128); } function sanitizeInputItemId(record: JsonRecord): JsonRecord { @@ -149,7 +153,7 @@ function sanitizeInputItem(item: unknown): unknown { if ( (next.type === "function_call" || next.type === "function_call_output") && typeof next.name === "string" && - !/^[a-zA-Z0-9_-]{1,128}$/.test(next.name) + !FUNCTION_NAME_VALID_RE.test(next.name) ) { next = { ...next, name: sanitizeFunctionName(next.name) }; } diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index ea240340ea..62e96ce1bc 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -61,6 +61,8 @@ import { getQoderUsage, parseQoderUserStatusUsage } from "./usage/qoder.ts"; export { parseQoderUserStatusUsage } from "./usage/qoder.ts"; import { getOpencodeUsage } from "./usage/opencode.ts"; import { getDeepseekUsage } from "./usage/deepseek.ts"; +import { getMoonshotOpenPlatformUsage } from "./moonshotQuotaFetcher.ts"; +import { isMoonshotOpenPlatformConnection } from "./usage/moonshotOpenPlatform.ts"; import { getDevinCliUsage } from "./usage/devinCli.ts"; import { getBailianCodingPlanUsage } from "./usage/bailian.ts"; import { getVertexUsage } from "./usage/vertex.ts"; @@ -111,6 +113,10 @@ export async function getUsageForProvider( ) { const { id, provider, accessToken, apiKey, providerSpecificData, projectId, email } = connection; + if (isMoonshotOpenPlatformConnection(connection)) { + return await getMoonshotOpenPlatformUsage(connection); + } + switch (provider) { case "github": return await getGitHubUsage(accessToken, providerSpecificData); @@ -168,6 +174,9 @@ export async function getUsageForProvider( return await getNanoGptUsage(apiKey || ""); case "deepseek": return await getDeepseekUsage(id || "", apiKey || ""); + case "moonshot": + case "kimi": + return await getMoonshotOpenPlatformUsage(connection); case "openrouter": return await getOpenrouterUsage(id || "", apiKey || "", providerSpecificData); case "opencode": diff --git a/open-sse/services/usage/fetcherProviders.ts b/open-sse/services/usage/fetcherProviders.ts index bfcc5da710..05a3b47225 100644 --- a/open-sse/services/usage/fetcherProviders.ts +++ b/open-sse/services/usage/fetcherProviders.ts @@ -45,6 +45,8 @@ export const USAGE_FETCHER_PROVIDERS = [ "qwen-cloud-token-plan", "nanogpt", "deepseek", + "moonshot", + "kimi", "opencode", "opencode-zen", "xiaomi-mimo", diff --git a/open-sse/services/usage/grokCli.ts b/open-sse/services/usage/grokCli.ts index 08396cfb2f..bbd76199b8 100644 --- a/open-sse/services/usage/grokCli.ts +++ b/open-sse/services/usage/grokCli.ts @@ -239,9 +239,12 @@ export async function getGrokCliUsage(accessToken?: string) { const config = billing.config; const resetAt = config.currentPeriod?.end || null; const quotas: Record> = {}; - if (config.creditUsagePercent != null) { - quotas.weekly = percentageQuota(config.creditUsagePercent, resetAt); - } + // SuperGrokPro (and proto3 omit-zero) billing configs often omit + // creditUsagePercent / productUsage. A present config object is a + // successful billing read, so treat a missing percent as 0% used and + // still render a weekly bar. A missing config still returns + // "Grok Build billing status unavailable" above — that path is unchanged. + quotas.weekly = percentageQuota(config.creditUsagePercent ?? 0, resetAt); Object.assign(quotas, buildProductQuotas(config.productUsage, resetAt)); const autoTopUpResponse = userId diff --git a/open-sse/services/usage/moonshotOpenPlatform.ts b/open-sse/services/usage/moonshotOpenPlatform.ts new file mode 100644 index 0000000000..a652aacb36 --- /dev/null +++ b/open-sse/services/usage/moonshotOpenPlatform.ts @@ -0,0 +1,76 @@ +/** + * Moonshot Open Platform host recognition. + * + * Distinguishes prepaid Open Platform keys (api.moonshot.cn / api.moonshot.ai) + * from Kimi Coding Plan (api.kimi.com/coding). Custom compatible nodes are + * identified by baseUrl host, not by provider id (those ids are uuids). + */ + +import { moonshotProvider } from "../../config/providers/registry/moonshot/index.ts"; +import { kimiProvider } from "../../config/providers/registry/kimi/index.ts"; + +export const MOONSHOT_OPEN_PLATFORM_HOSTS: ReadonlySet = new Set([ + "api.moonshot.cn", + "api.moonshot.ai", +]); + +export type MoonshotOriginConnection = { + provider?: string; + providerSpecificData?: unknown; +}; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function parseMoonshotOrigin(baseUrl: string | null | undefined): string | null { + if (typeof baseUrl !== "string" || baseUrl.trim() === "") return null; + let url: URL; + try { + url = new URL(baseUrl.trim()); + } catch { + return null; + } + if (url.protocol !== "https:" && url.protocol !== "http:") return null; + const host = url.hostname.toLowerCase(); + if (!MOONSHOT_OPEN_PLATFORM_HOSTS.has(host)) return null; + const port = url.port ? `:${url.port}` : ""; + return `${url.protocol}//${host}${port}`; +} + +export function moonshotBalanceUrl(origin: string): string { + return `${origin}/v1/users/me/balance`; +} + +function registryDefaultOrigin(provider: string | undefined): string | null { + if (provider === "moonshot") return parseMoonshotOrigin(moonshotProvider.baseUrl); + if (provider === "kimi") return parseMoonshotOrigin(kimiProvider.baseUrl); + return null; +} + +export function resolveMoonshotOrigin( + connection: MoonshotOriginConnection, + nodeBaseUrl?: string | null, +): string | null { + const psd = asRecord(connection.providerSpecificData); + const fromPsd = typeof psd.baseUrl === "string" ? parseMoonshotOrigin(psd.baseUrl) : null; + if (fromPsd) return fromPsd; + const fromNode = parseMoonshotOrigin(nodeBaseUrl); + if (fromNode) return fromNode; + return registryDefaultOrigin(connection.provider); +} + +export function isMoonshotOpenPlatformConnection( + connection: MoonshotOriginConnection, + nodeBaseUrl?: string | null, +): boolean { + return resolveMoonshotOrigin(connection, nodeBaseUrl) !== null; +} + +/** Account-level empty wallet on Open Platform. Narrower than any compatible 429. */ +export function isMoonshotAccountBalanceExhausted(errorText: string | null | undefined): boolean { + const lower = String(errorText || "").toLowerCase(); + return lower.includes("insufficient balance") || lower.includes("exceeded_current_quota"); +} diff --git a/open-sse/services/usage/supportedProviders.ts b/open-sse/services/usage/supportedProviders.ts index dc088fa1c4..b8e0481b86 100644 --- a/open-sse/services/usage/supportedProviders.ts +++ b/open-sse/services/usage/supportedProviders.ts @@ -41,6 +41,8 @@ export const USAGE_SUPPORTED_PROVIDERS: readonly string[] = [ "crof", "nanogpt", "deepseek", + "moonshot", + "kimi", "xiaomi-mimo", "xiaomi-mimo-token-plan", "vertex", diff --git a/open-sse/translator/bootstrap.ts b/open-sse/translator/bootstrap.ts index df852d483c..bd870e934a 100644 --- a/open-sse/translator/bootstrap.ts +++ b/open-sse/translator/bootstrap.ts @@ -5,6 +5,7 @@ import "./request/claude-to-openai.ts"; import "./request/openai-to-claude.ts"; +import "./request/openai-to-clova.ts"; import "./request/gemini-to-openai.ts"; import "./request/openai-to-gemini.ts"; import "./request/antigravity-to-openai.ts"; @@ -15,6 +16,7 @@ import "./request/claude-to-gemini.ts"; import "./response/claude-to-openai.ts"; import "./response/openai-to-claude.ts"; +import "./response/clova-to-openai.ts"; import "./response/gemini-to-openai.ts"; import "./response/gemini-to-claude.ts"; import "./response/openai-to-antigravity.ts"; diff --git a/open-sse/translator/formats.ts b/open-sse/translator/formats.ts index 4e0bd391f0..1349d30963 100644 --- a/open-sse/translator/formats.ts +++ b/open-sse/translator/formats.ts @@ -5,6 +5,8 @@ export const FORMATS = { OPENAI_RESPONSE: "openai-response", CLAUDE: "claude", GEMINI: "gemini", + /** Naver CLOVA Studio Chat Completions v3 (native envelope, model in URL path). */ + CLOVA: "clova", CODEX: "codex", ANTIGRAVITY: "antigravity", KIRO: "kiro", diff --git a/open-sse/translator/request/openai-to-clova.ts b/open-sse/translator/request/openai-to-clova.ts new file mode 100644 index 0000000000..62bd519c66 --- /dev/null +++ b/open-sse/translator/request/openai-to-clova.ts @@ -0,0 +1,375 @@ +/** + * OpenAI → Naver CLOVA Studio "Chat Completions v3" request translator. + * + * Wire format: `POST https://clovastudio.stream.ntruss.com/v3/chat-completions/{modelName}` + * + * Everything below that is marked "live-verified" was confirmed against the real + * API on 2026-09-01 — several of these rules contradict a plausible reading of + * the vendor docs, so they are recorded with the evidence. + * + * Vendor docs: + * - text/image: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3 + * - thinking: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-thinking + * - FC: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-fc + * - SO: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3-so + */ +import { register } from "../registry.ts"; +import { FORMATS } from "../formats.ts"; + +/** Output cap for the non-reasoning v3 models (HCX-005, HCX-DASH-002). */ +export const CLOVA_V3_MAX_OUTPUT_TOKENS = 4096; + +/** Output cap for the reasoning model (HCX-007) — includes thinking tokens. */ +export const CLOVA_V3_REASONING_MAX_OUTPUT_TOKENS = 32768; + +/** + * Function calling rejects any cap below 1024 (live-verified: `40001 Invalid + * parameter: tools, maxTokens`). + */ +export const CLOVA_V3_MIN_TOOL_TOKENS = 1024; + +export const CLOVA_V3_REASONING_MODELS: ReadonlySet = new Set(["HCX-007"]); + +export const CLOVA_V3_VISION_MODELS: ReadonlySet = new Set(["HCX-005"]); + +/** + * All three v3 models accept function calling (live-verified). HCX-007 needs + * `thinking.effort: "none"` alongside it or the call fails with + * `40001 Invalid parameter: tools, thinking`. + */ +export const CLOVA_V3_FUNCTION_CALLING_MODELS: ReadonlySet = new Set([ + "HCX-005", + "HCX-007", + "HCX-DASH-002", +]); + +/** Structured Outputs is HCX-007 only (live-verified: HCX-005 rejects `thinking`). */ +export const CLOVA_V3_STRUCTURED_OUTPUT_MODELS: ReadonlySet = new Set(["HCX-007"]); + +const CLOVA_THINKING_EFFORTS: ReadonlySet = new Set(["none", "low", "medium", "high"]); + +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function nonEmptyString(value: unknown): string { + return typeof value === "string" && value.length > 0 ? value : ""; +} + +export function isClovaReasoningModel(model: string): boolean { + return typeof model === "string" && CLOVA_V3_REASONING_MODELS.has(model.toUpperCase()); +} + +export function isClovaVisionModel(model: string): boolean { + return typeof model === "string" && CLOVA_V3_VISION_MODELS.has(model.toUpperCase()); +} + +export function isClovaFunctionCallingModel(model: string): boolean { + return typeof model === "string" && CLOVA_V3_FUNCTION_CALLING_MODELS.has(model.toUpperCase()); +} + +export function isClovaStructuredOutputModel(model: string): boolean { + return typeof model === "string" && CLOVA_V3_STRUCTURED_OUTPUT_MODELS.has(model.toUpperCase()); +} + +function clampNumeric(value: unknown, min: number, max: number): number | null { + const n = typeof value === "string" ? Number(value) : value; + if (typeof n !== "number" || !Number.isFinite(n)) return null; + return Math.min(Math.max(n, min), max); +} + +/** + * Map OpenAI `reasoning_effort` onto CLOVA's `thinking.effort`. + * `minimal` collapses to `low`; unrecognised values are dropped so CLOVA applies + * its own default (`low`). + */ +export function toClovaThinkingEffort(reasoningEffort: unknown): string { + if (typeof reasoningEffort !== "string") return ""; + const effort = reasoningEffort.toLowerCase(); + if (effort === "minimal") return "low"; + return CLOVA_THINKING_EFFORTS.has(effort) ? effort : ""; +} + +/** Flatten OpenAI message content into a single string (text only). */ +function contentToString(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return content == null ? "" : String(content); + return content + .map((part) => + part && typeof part === "object" && typeof part.text === "string" ? part.text : "" + ) + .filter(Boolean) + .join("\n"); +} + +/** + * Convert an OpenAI `content` value into CLOVA v3 typed content parts. + * + * Both image transports work (live-verified): a public URL becomes + * `imageUrl.url`, and a `data:` URL becomes `dataUri.data` — which must keep the + * FULL `data:;base64,` prefix or CLOVA rejects the request with + * `40001 Invalid parameter`. + */ +export function toClovaContent( + content: unknown, + supportsImages: boolean +): Array> { + if (typeof content === "string") { + return [{ type: "text", text: content }]; + } + + if (!Array.isArray(content)) { + return [{ type: "text", text: content == null ? "" : String(content) }]; + } + + const parts = content + .map((part) => toClovaContentPart(part, supportsImages)) + .filter((part): part is JsonRecord => part !== null); + + // CLOVA rejects a message with an empty content array, so always emit a part. + return parts.length > 0 ? parts : [{ type: "text", text: "" }]; +} + +function toClovaContentPart(part: unknown, supportsImages: boolean): JsonRecord | null { + const record = toRecord(part); + if (!record) return null; + + const text = nonEmptyString(record.text); + if (record.type === "text" || text) return text ? { type: "text", text } : null; + if (record.type !== "image_url" || !supportsImages) return null; + + const imageUrl = toRecord(record.image_url); + const url = nonEmptyString(imageUrl?.url) || nonEmptyString(record.url); + if (!url) return null; + return url.startsWith("data:") + ? { type: "image_url", dataUri: { data: url } } + : { type: "image_url", imageUrl: { url } }; +} + +/** Parse OpenAI's JSON-string tool arguments into the object CLOVA expects. */ +function toolArgumentsToObject(raw: unknown): Record { + if (raw == null) return {}; + if (typeof raw === "object") return raw as Record; + if (typeof raw !== "string" || !raw.trim()) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** + * Convert OpenAI tool declarations into CLOVA's `tools` array. + * The shapes are nearly identical; empty declarations are skipped because CLOVA + * rejects a tool without a name. + */ +export function toClovaTools(tools: unknown): Array> { + if (!Array.isArray(tools)) return []; + return tools.map(toClovaTool).filter((tool): tool is JsonRecord => tool !== null); +} + +function toClovaTool(tool: unknown): JsonRecord | null { + const record = toRecord(tool); + if (!record) return null; + const fn = toRecord(record.function); + const name = nonEmptyString(fn?.name) || nonEmptyString(record.name); + if (!name) return null; + + const description = + nonEmptyString(fn?.description) || nonEmptyString(record.description) || `Tool: ${name}`; + const parameters = fn?.parameters ?? record.parameters; + return { + type: "function", + function: { + name, + description, + ...(parameters ? { parameters } : {}), + }, + }; +} + +/** + * Which mutually-exclusive v3 mode does this request use? + * + * CLOVA forbids combining function calling with thinking or images, and forbids + * combining structured outputs with either. Exactly one mode is chosen. + */ +export function resolveClovaMode( + model: string, + body: Record +): "tools" | "structured" | "plain" { + const tools = toClovaTools(body?.tools); + if (tools.length > 0 && isClovaFunctionCallingModel(model)) return "tools"; + + const format = toRecord(body?.response_format); + const wantsSchema = format && (format.type === "json_schema" || format.type === "json_object"); + if (wantsSchema && isClovaStructuredOutputModel(model)) return "structured"; + + return "plain"; +} + +type ClovaMode = "tools" | "structured" | "plain"; + +function normalizeMessageRole(role: unknown): "assistant" | "system" | "user" { + return role === "assistant" || role === "system" ? role : "user"; +} + +function toClovaToolCall(call: unknown): JsonRecord { + const record = toRecord(call) ?? {}; + const fn = toRecord(record.function); + return { + id: record.id ?? "", + type: "function", + function: { + name: fn?.name ?? record.name ?? "", + arguments: toolArgumentsToObject(fn?.arguments ?? record.arguments), + }, + }; +} + +function toClovaToolModeMessage(message: unknown): JsonRecord { + const record = toRecord(message) ?? {}; + if (record.role === "tool") { + return { + role: "tool", + content: contentToString(record.content), + ...(record.tool_call_id ? { toolCallId: String(record.tool_call_id) } : {}), + }; + } + + const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : []; + if (record.role === "assistant" && toolCalls.length > 0) { + return { + role: "assistant", + content: "", + toolCalls: toolCalls.map(toClovaToolCall), + }; + } + return { + role: normalizeMessageRole(record.role), + content: contentToString(record.content), + }; +} + +function toClovaPlainMessage(message: unknown, supportsImages: boolean): JsonRecord { + const record = toRecord(message) ?? {}; + return { + role: normalizeMessageRole(record.role), + content: toClovaContent(record.content, supportsImages), + }; +} + +function toClovaMessages(body: JsonRecord, mode: ClovaMode, supportsImages: boolean): JsonRecord[] { + const messages = Array.isArray(body.messages) ? body.messages : []; + return messages.map((message) => + mode === "tools" + ? toClovaToolModeMessage(message) + : toClovaPlainMessage(message, supportsImages) + ); +} + +function applyThinking(payload: JsonRecord, body: JsonRecord, reasoning: boolean, mode: ClovaMode) { + if (!reasoning) return; + const effort = toClovaThinkingEffort(body.reasoning_effort); + if (mode === "tools" || mode === "structured") { + payload.thinking = { effort: "none" }; + } else if (effort) { + payload.thinking = { effort }; + } +} + +function applySampling(payload: JsonRecord, body: JsonRecord): void { + const temperature = clampNumeric(body.temperature, 0, 1); + if (temperature !== null) payload.temperature = temperature; + const topP = clampNumeric(body.top_p, 0, 1); + if (topP !== null && topP > 0) payload.topP = topP; + const topK = clampNumeric(body.top_k, 0, 128); + if (topK !== null && topK > 0) payload.topK = topK; + const penalty = clampNumeric(body.repetition_penalty, 0, 2); + if (penalty !== null && penalty > 0) payload.repetitionPenalty = penalty; +} + +function applyOutputCap( + payload: JsonRecord, + body: JsonRecord, + reasoning: boolean, + mode: ClovaMode +): void { + const cap = reasoning ? CLOVA_V3_REASONING_MAX_OUTPUT_TOKENS : CLOVA_V3_MAX_OUTPUT_TOKENS; + const key = reasoning ? "maxCompletionTokens" : "maxTokens"; + let tokens = clampNumeric(body.max_completion_tokens ?? body.max_tokens, 1, cap); + if (mode === "tools") { + const floor = Math.min(CLOVA_V3_MIN_TOOL_TOKENS, cap); + tokens = tokens === null ? floor : Math.max(tokens, floor); + } + if (tokens !== null) payload[key] = tokens; +} + +function responseSchema(body: JsonRecord): unknown { + const format = toRecord(body.response_format); + const jsonSchema = toRecord(format?.json_schema); + return jsonSchema?.schema ?? format?.schema; +} + +function applyModeFields(payload: JsonRecord, body: JsonRecord, mode: ClovaMode): void { + if (mode === "tools") { + payload.tools = toClovaTools(body.tools); + if (body.tool_choice === "none") payload.toolChoice = "none"; + if (body.tool_choice === "auto" || body.tool_choice === "required") { + payload.toolChoice = "auto"; + } + return; + } + if (mode !== "structured") return; + const schema = responseSchema(body); + if (schema && typeof schema === "object") { + payload.responseFormat = { type: "json", schema }; + } else { + delete payload.thinking; + } +} + +function applyPlainOptions( + payload: JsonRecord, + body: JsonRecord, + reasoning: boolean, + mode: ClovaMode +): void { + if (mode === "plain" && !reasoning) { + if (Array.isArray(body.stop) && body.stop.length > 0) { + payload.stop = body.stop.filter((value) => typeof value === "string"); + } else if (typeof body.stop === "string" && body.stop) { + payload.stop = [body.stop]; + } + } + const seed = clampNumeric(body.seed, 0, 4294967295); + if (seed !== null && seed > 0) payload.seed = Math.floor(seed); + if (body.include_ai_filters === true) payload.includeAiFilters = true; +} + +/** Build the CLOVA Studio v3 request body from an OpenAI Chat Completions body. */ +export function buildClovaPayload( + model: string, + body: Record, + stream: boolean, + credentials?: Record | null +): Record { + void stream; + void credentials; + const reasoning = isClovaReasoningModel(model); + const mode = resolveClovaMode(model, body); + const supportsImages = mode === "plain" && isClovaVisionModel(model); + const payload: JsonRecord = { messages: toClovaMessages(body, mode, supportsImages) }; + + applyThinking(payload, body, reasoning, mode); + applySampling(payload, body); + applyOutputCap(payload, body, reasoning, mode); + applyModeFields(payload, body, mode); + applyPlainOptions(payload, body, reasoning, mode); + return payload; +} + +register(FORMATS.OPENAI, FORMATS.CLOVA, buildClovaPayload, null); diff --git a/open-sse/translator/response/clova-to-openai.ts b/open-sse/translator/response/clova-to-openai.ts new file mode 100644 index 0000000000..4d2b9cee54 --- /dev/null +++ b/open-sse/translator/response/clova-to-openai.ts @@ -0,0 +1,354 @@ +/** + * Naver CLOVA Studio "Chat Completions v3" → OpenAI response translator. + * + * CLOVA v3 streams as SSE with **named events**: + * + * ``` + * id: + * event: token + * data: {"message":{"role":"assistant","content":"안"},"finishReason":null,...} + * + * id: + * event: result + * data: {"message":{"role":"assistant","content":"안녕"},"finishReason":"stop", + * "usage":{"promptTokens":20,"completionTokens":5,"totalTokens":25}} + * ``` + * + * Three traps this translator exists to defuse: + * + * 1. **`event: token` carries an incremental delta, but `event: result` repeats + * the COMPLETE text.** Concatenating both duplicates the whole answer at the + * end of the stream, so the result event is treated as a terminal snapshot: + * it contributes `finish_reason` + `usage` only. + * 2. **Function-calling streams deliver arguments as `partialJson` fragments.** + * The first token carries the tool `id` + `name`; every later token carries + * only a JSON fragment (`{`, `"location`, `":`, ` "`, `Se`, `oul`, `"}`), + * which have to be reassembled into OpenAI's `tool_calls[].function.arguments` + * string. The terminal frame repeats the finished call, so — same rule as the + * text snapshot — it is not re-emitted. + * 3. **Failures can arrive as an in-stream payload** whose `status.code` is not + * `20000`, not just as an HTTP error. Those are surfaced through + * `state.upstreamError` so stream.ts fails the request out and combo fallback + * can run, mirroring the Gemini translator. + * + * Docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3 + */ +import { register } from "../registry.ts"; +import { FORMATS } from "../formats.ts"; + +/** CLOVA's success status code (a string, not an HTTP number). */ +const CLOVA_STATUS_OK = "20000"; + +type JsonRecord = Record; + +interface ClovaStreamState extends JsonRecord { + responseId?: string; + created?: number; + model?: string; + chunkIndex?: number; + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; + upstreamError?: { status: number; type: string; code: string; message: string }; + toolCallStarted?: boolean; + finishReason?: string; +} + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +/** Map a CLOVA `finishReason` onto the OpenAI vocabulary. */ +function mapFinishReason(reason: unknown): string { + switch (String(reason || "")) { + case "length": + return "length"; + case "tool_calls": + return "tool_calls"; + case "content_filter": + return "content_filter"; + default: + return "stop"; + } +} + +/** + * Map a CLOVA string status code onto an HTTP status for error surfacing. + * Codes are 5-digit strings: `2xxxx` success, `4xxxx` client, `5xxxx` server. + */ +function httpStatusFromClovaCode(code: unknown): number { + const first = String(code || "").charAt(0); + if (first === "4") return 400; + return 502; +} + +/** + * Parse one raw SSE frame into `{ event, data }`. + * CLOVA emits `id:` / `event:` / `data:` lines per frame. + */ +export function parseClovaSseFrame(raw: string): { event: string; data: unknown } | null { + if (typeof raw !== "string" || !raw.trim()) return null; + + let event = ""; + let dataLine = ""; + + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("event:")) { + event = trimmed.slice(6).trim(); + } else if (trimmed.startsWith("data:")) { + dataLine = trimmed.slice(5).trim(); + } + } + + if (!dataLine) return null; + + try { + return { event, data: JSON.parse(dataLine) }; + } catch { + return null; + } +} + +function baseChunk(state: ClovaStreamState): Record { + return { + id: state.responseId, + object: "chat.completion.chunk", + created: state.created, + model: state.model || "clova", + }; +} + +/** + * Build one OpenAI delta chunk. + * + * `field` selects the delta key: `"content"` for the visible answer and + * `"reasoning_content"` for CLOVA's `thinkingContent` (HCX-007). + */ +function deltaChunk( + state: ClovaStreamState, + content: string, + field = "content" +): Record { + const chunk = baseChunk(state); + chunk.choices = [ + { + index: 0, + delta: { + ...((state.chunkIndex ?? 0) === 0 ? { role: "assistant" } : {}), + [field]: content, + }, + finish_reason: null, + }, + ]; + state.chunkIndex = (state.chunkIndex ?? 0) + 1; + return chunk; +} + +/** First tool-call chunk: carries id + name and opens an empty argument string. */ +function toolCallStartChunk( + state: ClovaStreamState, + id: string, + name: string +): Record { + const chunk = baseChunk(state); + chunk.choices = [ + { + index: 0, + delta: { + ...((state.chunkIndex ?? 0) === 0 ? { role: "assistant" } : {}), + tool_calls: [ + { + index: 0, + id: id || `call_${state.responseId}`, + type: "function", + function: { name, arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ]; + state.chunkIndex = (state.chunkIndex ?? 0) + 1; + return chunk; +} + +/** Subsequent tool-call chunk: appends one `partialJson` fragment. */ +function toolCallArgumentsChunk( + state: ClovaStreamState, + fragment: string +): Record { + const chunk = baseChunk(state); + chunk.choices = [ + { + index: 0, + delta: { tool_calls: [{ index: 0, function: { arguments: fragment } }] }, + finish_reason: null, + }, + ]; + state.chunkIndex = (state.chunkIndex ?? 0) + 1; + return chunk; +} + +function terminalChunk(state: ClovaStreamState, finishReason: string): Record { + const chunk = baseChunk(state); + chunk.choices = [{ index: 0, delta: {}, finish_reason: finishReason }]; + if (state.usage) chunk.usage = state.usage; + return chunk; +} + +function recordUsage(state: ClovaStreamState, usage: unknown): void { + const record = toRecord(usage); + if (!record) return; + const prompt = Number(record.promptTokens) || 0; + const completion = Number(record.completionTokens) || 0; + const total = Number(record.totalTokens) || prompt + completion; + state.usage = { + prompt_tokens: prompt, + completion_tokens: completion, + total_tokens: total, + }; +} + +function recordUpstreamError(state: ClovaStreamState, code: unknown, message: unknown): void { + const status = httpStatusFromClovaCode(code); + state.upstreamError = { + status, + type: status === 429 ? "rate_limit_error" : "server_error", + code: String(code || "clova_error"), + message: typeof message === "string" && message ? message : "CLOVA Studio upstream failure", + }; +} + +interface DecodedClovaChunk { + event: string; + data: JsonRecord; +} + +function initializeState(state: ClovaStreamState): void { + if (state.responseId) return; + state.responseId = `chatcmpl-${Date.now()}`; + state.created = Math.floor(Date.now() / 1000); + state.chunkIndex = 0; +} + +function decodeClovaChunk(chunk: unknown): DecodedClovaChunk | null { + if (typeof chunk === "string") { + const frame = parseClovaSseFrame(chunk); + const data = toRecord(frame?.data); + return frame && data ? { event: frame.event, data } : null; + } + const data = toRecord(chunk); + if (!data) return null; + return { event: String(data.event || data._eventType || ""), data }; +} + +function handleErrorEnvelope(state: ClovaStreamState, event: string, data: JsonRecord): boolean { + const status = toRecord(data.status); + const statusCode = status?.code ?? data.statusCode; + if (statusCode != null && String(statusCode) !== CLOVA_STATUS_OK) { + recordUpstreamError(state, statusCode, status?.message ?? data.message); + return true; + } + + const error = toRecord(data.error); + if (event !== "error" && !error) return false; + const source = error ?? data; + const errorStatus = toRecord(source.status); + recordUpstreamError( + state, + errorStatus?.code ?? source.code, + errorStatus?.message ?? source.message + ); + return true; +} + +function toolCallDelta(state: ClovaStreamState, call: unknown): Record | null { + const record = toRecord(call); + const fn = toRecord(record?.function); + if (!record || !fn) return null; + const id = typeof record.id === "string" ? record.id : ""; + const name = typeof fn.name === "string" ? fn.name : ""; + if (id || name) { + if (state.toolCallStarted) return null; + state.toolCallStarted = true; + return toolCallStartChunk(state, id, name); + } + return typeof fn.partialJson === "string" && fn.partialJson + ? toolCallArgumentsChunk(state, fn.partialJson) + : null; +} + +function toolCallDeltas( + state: ClovaStreamState, + message: JsonRecord +): Record | Array> | null { + if (!Array.isArray(message.toolCalls) || message.toolCalls.length === 0) return null; + const out = message.toolCalls + .map((call) => toolCallDelta(state, call)) + .filter((chunk): chunk is Record => chunk !== null); + if (out.length === 0) return null; + return out.length === 1 ? out[0] : out; +} + +function convertTokenEvent( + state: ClovaStreamState, + data: JsonRecord +): Record | Array> | null { + const message = toRecord(data.message) ?? data; + const toolDeltas = toolCallDeltas(state, message); + if (toolDeltas) return toolDeltas; + const thinking = message.thinkingContent ?? data.thinkingContent; + if (thinking) return deltaChunk(state, String(thinking), "reasoning_content"); + const content = message.content ?? data.content; + return content ? deltaChunk(state, String(content)) : null; +} + +function shouldEmitResultSnapshot( + state: ClovaStreamState, + isResultEvent: boolean, + snapshot: unknown +): snapshot is string { + return ( + !isResultEvent && (state.chunkIndex ?? 0) === 0 && typeof snapshot === "string" && !!snapshot + ); +} + +function convertResultEvent( + state: ClovaStreamState, + event: string, + data: JsonRecord +): Record | Array> | null { + const isResultEvent = event === "result" || event === "stop"; + const resultEnvelope = toRecord(data.result); + if (!isResultEvent && (event || !resultEnvelope)) return null; + + const result = resultEnvelope ?? data; + const message = toRecord(result.message); + recordUsage(state, result.usage); + const hasToolCalls = Array.isArray(message?.toolCalls) && message.toolCalls.length > 0; + const finishReason = hasToolCalls ? "tool_calls" : mapFinishReason(result.finishReason); + state.finishReason = finishReason; + + const snapshot = message?.content ?? result.content; + if (shouldEmitResultSnapshot(state, isResultEvent, snapshot)) { + return [deltaChunk(state, snapshot), terminalChunk(state, finishReason)]; + } + return terminalChunk(state, finishReason); +} + +/** Convert one CLOVA stream frame or JSON envelope into OpenAI chunk(s). */ +export function convertClovaToOpenAI( + chunk: unknown, + state: Record +): Record | Array> | null { + if (chunk == null) return null; + const streamState = state as ClovaStreamState; + initializeState(streamState); + const decoded = decodeClovaChunk(chunk); + if (!decoded) return null; + if (handleErrorEnvelope(streamState, decoded.event, decoded.data)) return null; + return decoded.event === "token" + ? convertTokenEvent(streamState, decoded.data) + : convertResultEvent(streamState, decoded.event, decoded.data); +} + +register(FORMATS.CLOVA, FORMATS.OPENAI, null, convertClovaToOpenAI); diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 4d255d40be..a2244f01f5 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -23,12 +23,12 @@ import { import { createEventEmitter } from "./openai-responses/eventEmitter.ts"; import { buildResponsesToolCallItem } from "./responsesToolItem.ts"; import { resolveRequestToolIdentity } from "./openai-responses/requestToolIdentity.ts"; +import { resolveLocalToolCallIndex } from "./openai-responses/toolCallLocalIndex.ts"; import { synthesizeCompletedToolCalls, computeFinishReason, withAssistantRoleOnFirstDelta, } from "./openai-responses/synthesizeCompletedToolCalls.ts"; - // normalizeUpstreamFailure is re-exported for external importers (tests). export { normalizeUpstreamFailure } from "./openai-responses/pureHelpers.ts"; @@ -506,7 +506,7 @@ function toolCallOutputIndexBase(state) { function emitToolCall(state, emit, tc) { const tcIdx = tc.index ?? 0; - const outputIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(tcIdx); + const outputIndex = toolCallOutputIndexBase(state) + resolveLocalToolCallIndex(state, tcIdx); const newCallId = tc.id; const funcName = tc.function?.name; @@ -609,7 +609,7 @@ function emitToolCall(state, emit, tc) { function closeToolCall(state, emit, idx, recordAsCompleted = true) { const callId = state.funcCallIds[idx]; if (callId && !state.funcItemDone[idx]) { - const normalizedIndex = toolCallOutputIndexBase(state) + normalizeOutputIndex(idx); + const normalizedIndex = toolCallOutputIndexBase(state) + resolveLocalToolCallIndex(state, idx); const args = state.funcArgsBuf[idx] || "{}"; const toolName = state.funcNames[idx] || ""; // See emitToolCall()'s isCustomTool comment — must stay in sync (both compute the diff --git a/open-sse/translator/response/openai-responses/toolCallLocalIndex.ts b/open-sse/translator/response/openai-responses/toolCallLocalIndex.ts new file mode 100644 index 0000000000..645278e1fd --- /dev/null +++ b/open-sse/translator/response/openai-responses/toolCallLocalIndex.ts @@ -0,0 +1,35 @@ +/** + * Remap a turn's raw upstream tool_calls delta `index` onto a local, + * contiguous, 0-based sequence in first-seen order. + * + * Live incident (2026-09-02, minimax-m3:free via OpenRouter/GMICloud): the + * upstream's own `index` doesn't reliably start at 0 or stay contiguous per + * turn — this turn's two calls arrived with raw index 1 and 2 (never 0). + * Adding that raw index straight onto toolCallOutputIndexBase() left a GAP + * in the emitted output_index sequence (0 for the message, then 2 and 3 for + * the calls — index 1 never used). A client that reads response.completed's + * final `output[]` array by ARRAY POSITION and expects position to equal + * output_index (the Responses API's own contract) reads output[1] (this + * turn's first call, real output_index 2) while looking it up under + * output_index 1, misses it, then reads output[2] (the second call, real + * output_index 3) under output_index 2 — landing on the FIRST call's tracked + * slot with a different call_id, which a spec-following client correctly + * treats as "stream changed output item identity" and aborts. + */ + +export type ToolCallLocalIndexState = { + toolCallLocalIndex?: Record; + toolCallLocalIndexNext?: number; +}; + +export function resolveLocalToolCallIndex( + state: ToolCallLocalIndexState, + tcIdx: string | number +): number { + if (!state.toolCallLocalIndex) state.toolCallLocalIndex = {}; + if (state.toolCallLocalIndex[tcIdx] === undefined) { + state.toolCallLocalIndex[tcIdx] = state.toolCallLocalIndexNext ?? 0; + state.toolCallLocalIndexNext = state.toolCallLocalIndex[tcIdx] + 1; + } + return state.toolCallLocalIndex[tcIdx]; +} diff --git a/open-sse/utils/composerToolCalls.ts b/open-sse/utils/composerToolCalls.ts index 916903a3ca..40d2c306af 100644 --- a/open-sse/utils/composerToolCalls.ts +++ b/open-sse/utils/composerToolCalls.ts @@ -48,6 +48,18 @@ const INNER_RE = new RegExp( // Match an arg separator. const ARG_SEP_RE = new RegExp(`<${FW}tool${SEP}sep${FW}>`, "gi"); +// Opening-only marker, matched on every streamed delta in the holdback path; +// kept as a module constant so it is compiled once instead of per call. +const OPEN_ONLY_RE = new RegExp(`<${FW}tool${SEP}calls${SEP}begin${FW}>`, "i"); + +// Parse helpers below run once per tool-call block / per argument value during +// streaming, so their literals are hoisted too. +const TRIM_EDGES_RE = /^\s+|\s+$/g; +const FIRST_SPACE_RE = /\s/; +const TRAILING_NEWLINES_RE = /\n+$/; +const INTEGER_RE = /^-?\d+$/; +const DECIMAL_RE = /^-?\d*\.\d+$/; + // Heuristic: any partial opening marker (start of `<|tool` ... without the // final `>`). Used by the streaming parser to know it must hold back text. const PARTIAL_OPEN_MARKER_RE = new RegExp( @@ -115,7 +127,7 @@ function generateToolCallId(index: number): string { function parseInnerCall(body: string): { name: string; arguments: string } | null { // Body starts with the tool name on (typically) its own line, optionally // surrounded by whitespace, then the first `<|tool▁sep|>`. - const trimmed = body.replace(/^\s+|\s+$/g, ""); + const trimmed = body.replace(TRIM_EDGES_RE, ""); // Split by argument separator first to isolate name + arg blocks. const segments = trimmed.split(ARG_SEP_RE); // First segment is the tool name (and any preamble whitespace). @@ -137,7 +149,7 @@ function parseInnerCall(body: string): { name: string; arguments: string } | nul let argName: string; let argValue: string; if (idxNl < 0) { - const idxSp = seg.search(/\s/); + const idxSp = seg.search(FIRST_SPACE_RE); if (idxSp < 0) { argName = seg.trim(); argValue = ""; @@ -155,7 +167,7 @@ function parseInnerCall(body: string): { name: string; arguments: string } | nul if (!argName) continue; // Strip the trailing newline before the next separator (the separator // marker itself was already consumed by the split). - argValue = argValue.replace(/\n+$/, ""); + argValue = argValue.replace(TRAILING_NEWLINES_RE, ""); // Attempt JSON parse so structured args (objects/arrays/numbers/bools) // come through as native JSON values rather than quoted strings. args[argName] = coerceArgValue(argValue); @@ -179,11 +191,11 @@ function coerceArgValue(raw: string): unknown { if (stripped === "true") return true; if (stripped === "false") return false; if (stripped === "null") return null; - if (/^-?\d+$/.test(stripped)) { + if (INTEGER_RE.test(stripped)) { const n = Number(stripped); if (Number.isSafeInteger(n)) return n; } - if (/^-?\d*\.\d+$/.test(stripped)) { + if (DECIMAL_RE.test(stripped)) { const n = Number(stripped); if (Number.isFinite(n)) return n; } @@ -295,8 +307,7 @@ export function feedStreamingChunk(state: StreamingState, accumulated: string): // 2. Look for an opening-only marker. If found, everything before it is // safe; everything after must be held until we see the closing marker. - const openOnlyRe = new RegExp(`<${FW}tool${SEP}calls${SEP}begin${FW}>`, "i"); - const openMatch = accumulated.match(openOnlyRe); + const openMatch = accumulated.match(OPEN_ONLY_RE); if (openMatch && openMatch.index !== undefined) { const safe = accumulated.slice(0, openMatch.index); const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : ""; diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 21164b6ed1..0e3f642682 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -24,6 +24,10 @@ import { encodeSelectedImageBody, type EncodedImage, } from "./cursorAgentProtobuf/imageEncoding.ts"; +import { + CURSOR_EFFORT_SUFFIXES, + resolveOneMillionContextModel, +} from "./cursorAgentProtobuf/requestedModelParameters.ts"; import { WT_VARINT, WT_LEN, @@ -41,6 +45,7 @@ import { findField, decodeStringField, decodeVarintField, + type Field, } from "./cursorAgentProtobuf/wire.ts"; // ─── Field numbers (from agent.proto descriptor) ─────────────────────────── @@ -311,8 +316,6 @@ export function normalizeCursorModelId(modelId: string): string { // Grok (`cursor-grok-*` / legacy `grok-*`) follows the Claude-style `effort` // parameter. Without the split, ids like `cursor-grok-4.5-high` return empty // turns (same symptom as #7289). Combined `-high-fast` is supported. -const CURSOR_EFFORT_SUFFIXES = ["low", "medium", "high", "xhigh", "max"] as const; - /** * If `normalized` starts with `prefix` and ends with one of the known effort * suffixes, split it into the base model id plus a `{id: paramId, value}` @@ -432,6 +435,8 @@ export function resolveRequestedModel( }; } } + const oneMillionContext = resolveOneMillionContextModel(normalized); + if (oneMillionContext) return oneMillionContext; // Live catalog is authoritative for exact ids (flattened effort variants). if (opts?.liveCatalogIds?.has(normalized)) { return { modelId: normalized, parameters: [] }; @@ -652,6 +657,41 @@ export type DecodedDelta = | { kind: "kv_server_message" } | { kind: "unknown"; field: number }; +type InteractionUpdateDecoder = (field: Field) => DecodedDelta[]; + +const INTERACTION_UPDATE_DECODERS: Partial> = { + [IU_TEXT_DELTA]: (field) => + field.wireType === WT_LEN + ? [{ kind: "text", text: decodeStringField(field.bytes, TDU_TEXT) }] + : [], + [IU_THINKING_DELTA]: (field) => + field.wireType === WT_LEN + ? [{ kind: "thinking", text: decodeStringField(field.bytes, TDU_TEXT) }] + : [], + [IU_THINKING_COMPLETED]: () => [{ kind: "thinking_complete" }], + [IU_TOOL_CALL_STARTED]: () => [{ kind: "tool_call_started" }], + [IU_TOOL_CALL_COMPLETED]: (field) => { + const deltas: DecodedDelta[] = []; + if (field.wireType === WT_LEN) { + const todoWrite = decodeNativeTodoWriteCompletion(field.bytes); + if (todoWrite) deltas.push(todoWrite); + } + deltas.push({ kind: "tool_call_completed" }); + return deltas; + }, + [IU_TOKEN_DELTA]: (field) => + field.wireType === WT_LEN + ? [{ kind: "token_delta", tokens: decodeVarintField(field.bytes, 1) }] + : [], + [IU_HEARTBEAT]: () => [{ kind: "heartbeat" }], + [IU_TURN_ENDED]: () => [{ kind: "turn_ended" }], +}; + +function decodeInteractionUpdate(field: Field): DecodedDelta[] { + const decoder = INTERACTION_UPDATE_DECODERS[field.fieldNumber]; + return decoder ? decoder(field) : [{ kind: "unknown", field: field.fieldNumber }]; +} + export function decodeAgentServerMessage(payload: Buffer): DecodedDelta[] { const out: DecodedDelta[] = []; for (const top of decodeFields(payload)) { @@ -661,45 +701,7 @@ export function decodeAgentServerMessage(payload: Buffer): DecodedDelta[] { } if (top.fieldNumber !== ASM_INTERACTION_UPDATE || top.wireType !== 2) continue; for (const update of decodeFields(top.bytes)) { - if (update.wireType !== 2 && update.wireType !== 0) continue; - switch (update.fieldNumber) { - case IU_TEXT_DELTA: - if (update.wireType === 2) { - out.push({ kind: "text", text: decodeStringField(update.bytes, TDU_TEXT) }); - } - break; - case IU_THINKING_DELTA: - if (update.wireType === 2) { - out.push({ kind: "thinking", text: decodeStringField(update.bytes, TDU_TEXT) }); - } - break; - case IU_THINKING_COMPLETED: - out.push({ kind: "thinking_complete" }); - break; - case IU_TOOL_CALL_STARTED: - out.push({ kind: "tool_call_started" }); - break; - case IU_TOOL_CALL_COMPLETED: - if (update.wireType === 2) { - const todoWrite = decodeNativeTodoWriteCompletion(update.bytes); - if (todoWrite) out.push(todoWrite); - } - out.push({ kind: "tool_call_completed" }); - break; - case IU_TOKEN_DELTA: - if (update.wireType === 2) { - out.push({ kind: "token_delta", tokens: decodeVarintField(update.bytes, 1) }); - } - break; - case IU_HEARTBEAT: - out.push({ kind: "heartbeat" }); - break; - case IU_TURN_ENDED: - out.push({ kind: "turn_ended" }); - break; - default: - out.push({ kind: "unknown", field: update.fieldNumber }); - } + out.push(...decodeInteractionUpdate(update)); } } return out; @@ -750,52 +752,44 @@ export type KvServerEvent = requestMetadata: Buffer | null; }; +function findLengthDelimitedField(fields: Field[], fieldNumber: number): Buffer | null { + const field = findField(fields, fieldNumber); + return field?.wireType === WT_LEN ? field.bytes : null; +} + +function decodeBlobId(payload: Buffer, fieldNumber: number): Buffer { + return findLengthDelimitedField(decodeFields(payload), fieldNumber) ?? Buffer.alloc(0); +} + +function decodeSetBlobArgs(payload: Buffer): { blobId: Buffer; blobData: Buffer } { + const fields = decodeFields(payload); + return { + blobId: findLengthDelimitedField(fields, SBA_BLOB_ID) ?? Buffer.alloc(0), + blobData: findLengthDelimitedField(fields, SBA_BLOB_DATA) ?? Buffer.alloc(0), + }; +} + export function decodeKvServerEvent(payload: Buffer): KvServerEvent | null { - for (const top of decodeFields(payload)) { - if (top.fieldNumber !== ASM_KV_SERVER_MESSAGE || top.wireType !== 2) continue; + const top = findField(decodeFields(payload), ASM_KV_SERVER_MESSAGE); + if (top?.wireType !== WT_LEN) return null; - let kvId = 0; - let getBlobArgs: Buffer | null = null; - let setBlobArgs: Buffer | null = null; - let requestMetadata: Buffer | null = null; - - for (const f of decodeFields(top.bytes)) { - if (f.fieldNumber === KSM_ID && f.wireType === 0) { - kvId = Number(f.varint); - } else if (f.fieldNumber === KSM_GET_BLOB_ARGS && f.wireType === 2) { - getBlobArgs = f.bytes; - } else if (f.fieldNumber === KSM_SET_BLOB_ARGS && f.wireType === 2) { - setBlobArgs = f.bytes; - } else if (f.fieldNumber === KSM_REQUEST_METADATA && f.wireType === 2) { - requestMetadata = f.bytes; - } - } - - if (getBlobArgs) { - // GetBlobArgs { blob_id (1): bytes } - let blobId: Buffer = Buffer.alloc(0); - for (const f of decodeFields(getBlobArgs)) { - if (f.fieldNumber === GBA_BLOB_ID && f.wireType === 2) { - blobId = f.bytes; - } - } - return { kind: "kv_get_blob", kvId, blobId, requestMetadata }; - } - if (setBlobArgs) { - // SetBlobArgs { blob_id (1): bytes, blob_data (2): bytes } - let blobId: Buffer = Buffer.alloc(0); - let blobData: Buffer = Buffer.alloc(0); - for (const f of decodeFields(setBlobArgs)) { - if (f.fieldNumber === SBA_BLOB_ID && f.wireType === 2) { - blobId = f.bytes; - } else if (f.fieldNumber === SBA_BLOB_DATA && f.wireType === 2) { - blobData = f.bytes; - } - } - return { kind: "kv_set_blob", kvId, blobId, blobData, requestMetadata }; - } + const fields = decodeFields(top.bytes); + const idField = findField(fields, KSM_ID); + const kvId = idField?.wireType === WT_VARINT ? Number(idField.varint) : 0; + const requestMetadata = findLengthDelimitedField(fields, KSM_REQUEST_METADATA); + const getBlobArgs = findLengthDelimitedField(fields, KSM_GET_BLOB_ARGS); + if (getBlobArgs) { + return { + kind: "kv_get_blob", + kvId, + blobId: decodeBlobId(getBlobArgs, GBA_BLOB_ID), + requestMetadata, + }; } - return null; + + const setBlobArgs = findLengthDelimitedField(fields, KSM_SET_BLOB_ARGS); + if (!setBlobArgs) return null; + return { kind: "kv_set_blob", kvId, ...decodeSetBlobArgs(setBlobArgs), requestMetadata }; } // ─── Phase 2: full ExecServerMessage variant decoder ─────────────────────── @@ -886,143 +880,121 @@ function decodeShellArgs(payload: Buffer): DecodedShellArgs { return decoded; } -export function decodeExecServerEvent(payload: Buffer): ExecServerEvent | null { - for (const top of decodeFields(payload)) { - if (top.fieldNumber !== ASM_EXEC_SERVER_MESSAGE || top.wireType !== 2) continue; +type ExecEventContext = { + execMsgId: number; + execId: string; + variantBytes: Buffer; +}; - let execMsgId = 0; - let execId = ""; - let variantField = 0; - let variantBytes: Buffer | null = null; +type ExecEventDecoder = (context: ExecEventContext) => ExecServerEvent; +type PathExecKind = "exec_read" | "exec_write" | "exec_delete" | "exec_ls"; +type ShellExecKind = "exec_shell" | "exec_shell_stream" | "exec_bg_shell"; - for (const f of decodeFields(top.bytes)) { - if (f.fieldNumber === ESM_ID && f.wireType === 0) { - execMsgId = Number(f.varint); - } else if (f.fieldNumber === ESM_EXEC_ID && f.wireType === 2) { - execId = f.bytes.toString("utf8"); - } else if (f.wireType === 2) { - // Any other LEN field is the variant payload. Take the first one we - // see — variants don't co-occur in a well-formed message. - if (variantField === 0) { - variantField = f.fieldNumber; - variantBytes = f.bytes; - } - } - } +function createPathExecEvent(kind: PathExecKind, context: ExecEventContext): ExecServerEvent { + return { + kind, + execMsgId: context.execMsgId, + execId: context.execId, + path: decodeStringField(context.variantBytes, ARG_PATH), + }; +} - if (variantBytes === null) continue; +function createShellExecEvent(kind: ShellExecKind, context: ExecEventContext): ExecServerEvent { + return { + kind, + execMsgId: context.execMsgId, + execId: context.execId, + ...decodeShellArgs(context.variantBytes), + }; +} - switch (variantField) { - case ESM_REQUEST_CONTEXT_ARGS: - return { kind: "exec_request_context", execMsgId, execId }; - case ESM_READ_ARGS: - return { - kind: "exec_read", - execMsgId, - execId, - path: decodeStringField(variantBytes, ARG_PATH), - }; - case ESM_WRITE_ARGS: - return { - kind: "exec_write", - execMsgId, - execId, - path: decodeStringField(variantBytes, ARG_PATH), - }; - case ESM_DELETE_ARGS: - return { - kind: "exec_delete", - execMsgId, - execId, - path: decodeStringField(variantBytes, ARG_PATH), - }; - case ESM_LS_ARGS: - return { - kind: "exec_ls", - execMsgId, - execId, - path: decodeStringField(variantBytes, ARG_PATH), - }; - case ESM_GREP_ARGS: - return { kind: "exec_grep", execMsgId, execId }; - case ESM_DIAGNOSTICS_ARGS: - return { kind: "exec_diagnostics", execMsgId, execId }; - case ESM_SHELL_ARGS: { - const shell = decodeShellArgs(variantBytes); - return { - kind: "exec_shell", - execMsgId, - execId, - ...shell, - }; - } - case ESM_SHELL_STREAM_ARGS: { - const shell = decodeShellArgs(variantBytes); - return { - kind: "exec_shell_stream", - execMsgId, - execId, - ...shell, - }; - } - case ESM_BACKGROUND_SHELL_SPAWN: { - const shell = decodeShellArgs(variantBytes); - return { - kind: "exec_bg_shell", - execMsgId, - execId, - ...shell, - }; - } - case ESM_FETCH_ARGS: - return { - kind: "exec_fetch", - execMsgId, - execId, - url: decodeStringField(variantBytes, ARG_FETCH_URL), - }; - case ESM_WRITE_SHELL_STDIN_ARGS: - return { kind: "exec_write_shell_stdin", execMsgId, execId }; - case ESM_MCP_ARGS: { - // McpArgs.args is map; each value is a protobuf- - // encoded google.protobuf.Value. Decode keys and value-bytes here, - // then convert each Value to its JSON shape. - let toolName = ""; - let toolCallId = ""; - const args: Record = {}; - for (const f of decodeFields(variantBytes)) { - if (f.wireType !== 2) continue; - if (f.fieldNumber === MCA_TOOL_NAME) { - toolName = f.bytes.toString("utf8"); - } else if (f.fieldNumber === MCA_NAME && !toolName) { - // tool_name (5) takes precedence; fall back to name (1) - toolName = f.bytes.toString("utf8"); - } else if (f.fieldNumber === MCA_TOOL_CALL_ID) { - toolCallId = f.bytes.toString("utf8"); - } else if (f.fieldNumber === MCA_ARGS) { - // FieldsEntry { key (1): string, value (2): bytes } - let key = ""; - let valueBytes: Buffer | null = null; - for (const entry of decodeFields(f.bytes)) { - if (entry.fieldNumber === MAP_KEY && entry.wireType === 2) { - key = entry.bytes.toString("utf8"); - } else if (entry.fieldNumber === MAP_VALUE && entry.wireType === 2) { - valueBytes = entry.bytes; - } - } - if (key && valueBytes !== null) { - args[key] = decodeProtobufValue(valueBytes); - } - } - } - return { kind: "exec_mcp", execMsgId, execId, toolName, toolCallId, args }; - } - default: - // Unknown variant — return null so caller can keep buffering. - return null; - } +function decodeMcpMapEntry(payload: Buffer): { key: string; value: unknown } | null { + const fields = decodeFields(payload); + const key = findLengthDelimitedField(fields, MAP_KEY)?.toString("utf8") ?? ""; + const valueBytes = findLengthDelimitedField(fields, MAP_VALUE); + return key && valueBytes ? { key, value: decodeProtobufValue(valueBytes) } : null; +} + +function decodeMcpExecEvent(context: ExecEventContext): ExecServerEvent { + const fields = decodeFields(context.variantBytes); + const canonicalName = findLengthDelimitedField(fields, MCA_TOOL_NAME); + const fallbackName = findLengthDelimitedField(fields, MCA_NAME); + const toolName = (canonicalName ?? fallbackName)?.toString("utf8") ?? ""; + const toolCallId = findLengthDelimitedField(fields, MCA_TOOL_CALL_ID)?.toString("utf8") ?? ""; + const args: Record = {}; + for (const field of fields) { + if (field.fieldNumber !== MCA_ARGS || field.wireType !== WT_LEN) continue; + const entry = decodeMcpMapEntry(field.bytes); + if (entry) args[entry.key] = entry.value; } - return null; + return { + kind: "exec_mcp", + execMsgId: context.execMsgId, + execId: context.execId, + toolName, + toolCallId, + args, + }; +} + +const EXEC_EVENT_DECODERS: Partial> = { + [ESM_REQUEST_CONTEXT_ARGS]: ({ execMsgId, execId }) => ({ + kind: "exec_request_context", + execMsgId, + execId, + }), + [ESM_READ_ARGS]: (context) => createPathExecEvent("exec_read", context), + [ESM_WRITE_ARGS]: (context) => createPathExecEvent("exec_write", context), + [ESM_DELETE_ARGS]: (context) => createPathExecEvent("exec_delete", context), + [ESM_LS_ARGS]: (context) => createPathExecEvent("exec_ls", context), + [ESM_GREP_ARGS]: ({ execMsgId, execId }) => ({ kind: "exec_grep", execMsgId, execId }), + [ESM_DIAGNOSTICS_ARGS]: ({ execMsgId, execId }) => ({ + kind: "exec_diagnostics", + execMsgId, + execId, + }), + [ESM_SHELL_ARGS]: (context) => createShellExecEvent("exec_shell", context), + [ESM_SHELL_STREAM_ARGS]: (context) => createShellExecEvent("exec_shell_stream", context), + [ESM_BACKGROUND_SHELL_SPAWN]: (context) => createShellExecEvent("exec_bg_shell", context), + [ESM_FETCH_ARGS]: ({ execMsgId, execId, variantBytes }) => ({ + kind: "exec_fetch", + execMsgId, + execId, + url: decodeStringField(variantBytes, ARG_FETCH_URL), + }), + [ESM_WRITE_SHELL_STDIN_ARGS]: ({ execMsgId, execId }) => ({ + kind: "exec_write_shell_stdin", + execMsgId, + execId, + }), + [ESM_MCP_ARGS]: decodeMcpExecEvent, +}; + +function decodeExecEventContext( + payload: Buffer +): (ExecEventContext & { variantField: number }) | null { + const top = findField(decodeFields(payload), ASM_EXEC_SERVER_MESSAGE); + if (top?.wireType !== WT_LEN) return null; + + const fields = decodeFields(top.bytes); + const idField = findField(fields, ESM_ID); + const variant = fields.find( + (field) => field.wireType === WT_LEN && field.fieldNumber !== ESM_EXEC_ID + ); + if (!variant || variant.wireType !== WT_LEN) return null; + return { + execMsgId: idField?.wireType === WT_VARINT ? Number(idField.varint) : 0, + execId: findLengthDelimitedField(fields, ESM_EXEC_ID)?.toString("utf8") ?? "", + variantField: variant.fieldNumber, + variantBytes: variant.bytes, + }; +} + +export function decodeExecServerEvent(payload: Buffer): ExecServerEvent | null { + const context = decodeExecEventContext(payload); + if (!context) return null; + const decoder = EXEC_EVENT_DECODERS[context.variantField]; + return decoder?.(context) ?? null; } /** @@ -1316,6 +1288,87 @@ export function jsonSchemaToProtobufValue(json: unknown): Buffer { * Handles all six Value variants: null, number (double), string, bool, * struct (object), list (array). Unknown fields are skipped. */ +type ProtobufValueDecodeResult = { value: unknown; nextPos: number }; +type ProtobufValueDecoder = ( + buf: Buffer, + pos: number, + wireType: number +) => ProtobufValueDecodeResult; + +function readLengthDelimitedPayload( + buf: Buffer, + pos: number, + wireType: number +): { payload: Buffer; nextPos: number } | null { + if (wireType !== WT_LEN) return null; + const [len, afterLength] = decodeVarint(buf, pos); + const lenN = checkedLen(len, afterLength, buf); + return { + payload: buf.subarray(afterLength, afterLength + lenN), + nextPos: afterLength + lenN, + }; +} + +function decodeNullValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const nextPos = wireType === WT_VARINT ? decodeVarint(buf, pos)[1] : pos; + return { value: null, nextPos }; +} + +function decodeNumberValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const valid = wireType === 1 && pos + 8 <= buf.length; + return { value: valid ? buf.readDoubleLE(pos) : 0, nextPos: valid ? pos + 8 : pos }; +} + +function decodeStringValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const decoded = readLengthDelimitedPayload(buf, pos, wireType); + return { + value: decoded?.payload.toString("utf8") ?? "", + nextPos: decoded?.nextPos ?? pos, + }; +} + +function decodeBoolValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + if (wireType !== WT_VARINT) return { value: false, nextPos: pos }; + const [value, nextPos] = decodeVarint(buf, pos); + return { value: value !== 0n, nextPos }; +} + +function decodeStructValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const decoded = readLengthDelimitedPayload(buf, pos, wireType); + return { + value: decoded ? decodeProtobufStruct(decoded.payload) : {}, + nextPos: decoded?.nextPos ?? pos, + }; +} + +function decodeListValue(buf: Buffer, pos: number, wireType: number): ProtobufValueDecodeResult { + const decoded = readLengthDelimitedPayload(buf, pos, wireType); + return { + value: decoded ? decodeProtobufList(decoded.payload) : [], + nextPos: decoded?.nextPos ?? pos, + }; +} + +const PROTOBUF_VALUE_DECODERS: Partial> = { + [VAL_NULL]: decodeNullValue, + [VAL_NUMBER]: decodeNumberValue, + [VAL_STRING]: decodeStringValue, + [VAL_BOOL]: decodeBoolValue, + [VAL_STRUCT]: decodeStructValue, + [VAL_LIST]: decodeListValue, +}; + +function skipUnknownProtobufField(buf: Buffer, pos: number, wireType: number): number { + if (wireType === WT_VARINT) return decodeVarint(buf, pos)[1]; + if (wireType === WT_LEN) { + const [len, afterLength] = decodeVarint(buf, pos); + return afterLength + checkedLen(len, afterLength, buf); + } + if (wireType === 1) return pos + 8; + if (wireType === 5) return pos + 4; + return pos; +} + export function decodeProtobufValue(buf: Buffer): unknown { let pos = 0; while (pos < buf.length) { @@ -1323,97 +1376,26 @@ export function decodeProtobufValue(buf: Buffer): unknown { pos = np; const fieldNumber = Number(t >> 3n); const wireType = Number(t & 0x7n); - switch (fieldNumber) { - case VAL_NULL: { - if (wireType === WT_VARINT) { - [, pos] = decodeVarint(buf, pos); - } - return null; - } - case VAL_NUMBER: { - if (wireType === 1 && pos + 8 <= buf.length) { - const value = buf.readDoubleLE(pos); - pos += 8; - return value; - } - return 0; - } - case VAL_STRING: { - if (wireType === WT_LEN) { - const [len, np2] = decodeVarint(buf, pos); - pos = np2; - const lenN = checkedLen(len, pos, buf); - const value = buf.subarray(pos, pos + lenN).toString("utf8"); - pos += lenN; - return value; - } - return ""; - } - case VAL_BOOL: { - if (wireType === WT_VARINT) { - const [val, np2] = decodeVarint(buf, pos); - pos = np2; - return val !== 0n; - } - return false; - } - case VAL_STRUCT: { - if (wireType === WT_LEN) { - const [len, np2] = decodeVarint(buf, pos); - pos = np2; - const lenN = checkedLen(len, pos, buf); - const inner = buf.subarray(pos, pos + lenN); - pos += lenN; - return decodeProtobufStruct(inner); - } - return {}; - } - case VAL_LIST: { - if (wireType === WT_LEN) { - const [len, np2] = decodeVarint(buf, pos); - pos = np2; - const lenN = checkedLen(len, pos, buf); - const inner = buf.subarray(pos, pos + lenN); - pos += lenN; - return decodeProtobufList(inner); - } - return []; - } - default: - // Skip unknown field - if (wireType === WT_VARINT) { - [, pos] = decodeVarint(buf, pos); - } else if (wireType === WT_LEN) { - const [len, np2] = decodeVarint(buf, pos); - pos = np2; - pos += Number(len); - } else if (wireType === 1) { - pos += 8; - } else if (wireType === 5) { - pos += 4; - } - } + const decoder = PROTOBUF_VALUE_DECODERS[fieldNumber]; + if (decoder) return decoder(buf, pos, wireType).value; + pos = skipUnknownProtobufField(buf, pos, wireType); } return null; } +function decodeProtobufStructEntry(payload: Buffer): { key: string; value: unknown } | null { + const fields = decodeFields(payload); + const key = findLengthDelimitedField(fields, MAP_KEY)?.toString("utf8") ?? ""; + const valueBytes = findLengthDelimitedField(fields, MAP_VALUE); + return key && valueBytes ? { key, value: decodeProtobufValue(valueBytes) } : null; +} + function decodeProtobufStruct(buf: Buffer): Record { const result: Record = {}; - for (const f of decodeFields(buf)) { - if (f.fieldNumber === STRUCT_FIELDS && f.wireType === 2) { - let key = ""; - let valueBytes: Buffer | null = null; - for (const entry of decodeFields(f.bytes)) { - if (entry.fieldNumber === MAP_KEY && entry.wireType === 2) { - key = entry.bytes.toString("utf8"); - } else if (entry.fieldNumber === MAP_VALUE && entry.wireType === 2) { - valueBytes = entry.bytes; - } - } - if (key && valueBytes) { - result[key] = decodeProtobufValue(valueBytes); - } - } + for (const field of decodeFields(buf)) { + if (field.fieldNumber !== STRUCT_FIELDS || field.wireType !== WT_LEN) continue; + const entry = decodeProtobufStructEntry(field.bytes); + if (entry) result[entry.key] = entry.value; } return result; } @@ -1477,6 +1459,39 @@ export type ChatMessage = { tool_call_id?: string; }; +function messageContentToText(content: ChatMessage["content"]): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((part) => (typeof part?.text === "string" ? part.text : "")) + .filter(Boolean) + .join("\n"); +} + +function assistantMessageLines(message: ChatMessage, text: string): string[] { + const lines = text ? [`Assistant: ${text}`] : []; + for (const toolCall of message.tool_calls ?? []) { + const name = toolCall.function?.name ?? "(unknown)"; + const args = toolCall.function?.arguments ?? ""; + lines.push(`Assistant called tool ${name} (${toolCall.id}) with arguments: ${args}`); + } + return lines; +} + +function chatMessageLines(message: ChatMessage): string[] { + const text = messageContentToText(message.content); + if (message.role === "user") return text ? [`User: ${text}`] : []; + if (message.role === "assistant") return assistantMessageLines(message, text); + if (message.role === "tool") { + return [`Tool result (${message.tool_call_id ?? "(unknown)"}): ${text}`]; + } + return text ? [`${message.role}: ${text}`] : []; +} + +function joinSystemText(systemTexts: string[], body: string): string { + return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${body}` : body; +} + /** * Flatten an OpenAI-shaped message list down to a single user-text string * suitable for cursor's UserMessage. The agent endpoint expects ONE user @@ -1490,57 +1505,23 @@ export type ChatMessage = { export function flattenMessages(messages: ChatMessage[]): string { if (!Array.isArray(messages) || messages.length === 0) return ""; - const partsToText = (content: ChatMessage["content"]): string => { - if (typeof content === "string") return content; - if (content == null) return ""; - if (!Array.isArray(content)) return ""; - return content - .map((p) => (typeof p?.text === "string" ? p.text : "")) - .filter(Boolean) - .join("\n"); - }; - // System instructions go first as a labeled prefix. (The cursor executor // routes system messages through the KV blob channel — see Phase 7 — but // this branch is kept for non-cursor callers.) const systemTexts = messages .filter((m) => m.role === "system") - .map((m) => partsToText(m.content)) + .map((m) => messageContentToText(m.content)) .filter(Boolean); const turn = messages.filter((m) => m.role !== "system"); // Single-user-message fast path (no tool_calls, no labels). if (turn.length === 1 && turn[0].role === "user" && !turn[0].tool_calls) { - const userText = partsToText(turn[0].content); - return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${userText}` : userText; + return joinSystemText(systemTexts, messageContentToText(turn[0].content)); } // Multi-turn / tool-using format. Each message is labeled. Tool calls // and tool results get their own labeled lines. - const lines: string[] = []; - for (const m of turn) { - const text = partsToText(m.content); - if (m.role === "user") { - if (text) lines.push(`User: ${text}`); - } else if (m.role === "assistant") { - if (text) lines.push(`Assistant: ${text}`); - if (Array.isArray(m.tool_calls)) { - for (const tc of m.tool_calls) { - const args = tc.function?.arguments ?? ""; - lines.push( - `Assistant called tool ${tc.function?.name ?? "(unknown)"} ` + - `(${tc.id}) with arguments: ${args}` - ); - } - } - } else if (m.role === "tool") { - const callId = m.tool_call_id ?? "(unknown)"; - lines.push(`Tool result (${callId}): ${text}`); - } else { - if (text) lines.push(`${m.role}: ${text}`); - } - } - const labelled = lines.join("\n\n"); - return systemTexts.length > 0 ? `${systemTexts.join("\n\n")}\n\n${labelled}` : labelled; + const labelled = turn.flatMap(chatMessageLines).join("\n\n"); + return joinSystemText(systemTexts, labelled); } diff --git a/open-sse/utils/cursorAgentProtobuf/requestedModelParameters.ts b/open-sse/utils/cursorAgentProtobuf/requestedModelParameters.ts new file mode 100644 index 0000000000..da431746eb --- /dev/null +++ b/open-sse/utils/cursorAgentProtobuf/requestedModelParameters.ts @@ -0,0 +1,113 @@ +export const CURSOR_EFFORT_SUFFIXES = ["low", "medium", "high", "xhigh", "max"] as const; + +type CursorRequestedModel = { + modelId: string; + parameters: Array<{ id: string; value: string }>; +}; + +const CURSOR_ONE_MILLION_SUFFIX = "-1m"; +const CURSOR_GPT_REASONING_LEVELS = ["none", ...CURSOR_EFFORT_SUFFIXES] as const; + +const CURSOR_CLAUDE_ONE_MILLION_FAMILIES = [ + { + legacyPrefix: "claude-fable-5-1", + modelId: "claude-fable-5-1", + supportsFast: false, + trailingThinking: false, + }, + { + legacyPrefix: "claude-opus-5", + modelId: "claude-opus-5", + supportsFast: true, + trailingThinking: false, + }, + { + legacyPrefix: "claude-opus-4-8", + modelId: "claude-opus-4-8", + supportsFast: true, + trailingThinking: false, + }, + { + legacyPrefix: "claude-sonnet-5", + modelId: "claude-sonnet-5", + supportsFast: false, + trailingThinking: false, + }, + { + legacyPrefix: "claude-4.6-sonnet", + modelId: "claude-sonnet-4-6", + supportsFast: false, + trailingThinking: true, + }, +] as const; + +type CursorClaudeOneMillionFamily = (typeof CURSOR_CLAUDE_ONE_MILLION_FAMILIES)[number]; + +function isCursorEffort(value: string): value is (typeof CURSOR_EFFORT_SUFFIXES)[number] { + return CURSOR_EFFORT_SUFFIXES.some((effort) => effort === value); +} + +function resolveGptOneMillionContextModel(legacyId: string): CursorRequestedModel | null { + const match = /^(gpt-5\.6-(?:sol|terra|luna))-(none|low|medium|high|xhigh|max)$/.exec(legacyId); + if (!match) return null; + + const [, modelId, reasoning] = match; + if (!CURSOR_GPT_REASONING_LEVELS.some((level) => level === reasoning)) return null; + return { + modelId, + parameters: [ + { id: "context", value: "1m" }, + { id: "reasoning", value: reasoning }, + { id: "fast", value: "false" }, + ], + }; +} + +function resolveClaudeOneMillionVariant( + legacyId: string, + family: CursorClaudeOneMillionFamily +): CursorRequestedModel | null { + const prefix = `${family.legacyPrefix}-`; + if (!legacyId.startsWith(prefix)) return null; + + let variant = legacyId.slice(prefix.length); + const fast = variant.endsWith("-fast"); + if (fast) variant = variant.slice(0, -"-fast".length); + if (fast && !family.supportsFast) return null; + + const trailingThinking = family.trailingThinking && variant.endsWith("-thinking"); + const leadingThinking = !family.trailingThinking && variant.startsWith("thinking-"); + if (trailingThinking) variant = variant.slice(0, -"-thinking".length); + if (leadingThinking) variant = variant.slice("thinking-".length); + if (!isCursorEffort(variant)) return null; + + const parameters = [ + { id: "thinking", value: String(trailingThinking || leadingThinking) }, + { id: "context", value: "1m" }, + { id: "effort", value: variant }, + ]; + if (family.supportsFast) parameters.push({ id: "fast", value: String(fast) }); + return { modelId: family.modelId, parameters }; +} + +function resolveClaudeOneMillionContextModel(legacyId: string): CursorRequestedModel | null { + for (const family of CURSOR_CLAUDE_ONE_MILLION_FAMILIES) { + const resolved = resolveClaudeOneMillionVariant(legacyId, family); + if (resolved) return resolved; + } + return null; +} + +/** + * Cursor reuses each legacy slug for both its default and 1M context variants, + * so the public catalog adds a terminal `-1m` discriminator. Translate that + * synthetic id to the canonical wire model plus the complete parameter set + * reported by Cursor's AvailableModels metadata. + */ +export function resolveOneMillionContextModel(normalized: string): CursorRequestedModel | null { + if (!normalized.endsWith(CURSOR_ONE_MILLION_SUFFIX)) return null; + const legacyId = normalized.slice(0, -CURSOR_ONE_MILLION_SUFFIX.length); + return ( + resolveGptOneMillionContextModel(legacyId) ?? resolveClaudeOneMillionContextModel(legacyId) + ); +} diff --git a/open-sse/utils/reasoningFields.ts b/open-sse/utils/reasoningFields.ts index 21fc22cab1..75b7cbe537 100644 --- a/open-sse/utils/reasoningFields.ts +++ b/open-sse/utils/reasoningFields.ts @@ -21,45 +21,61 @@ export function extractReasoningDetailsText(value: unknown): string { .join(""); } -export function getReadableReasoningValue(value: unknown): string { +/** + * Consolidated reasoning field extraction - single pass returns all categories + * to avoid 3-5 separate object traversals per chunk. + */ +export interface ReasoningFields { + readable: string; + unsupported: string; + any: string; + hasUnsupportedSignal: boolean; + hasAnySignal: boolean; +} + +export function extractReasoningFields(value: unknown): ReasoningFields { const record = asReasoningRecord(value); - return nonEmptyString(record.reasoning_content) || nonEmptyString(record.reasoning); + + const readable = nonEmptyString(record.reasoning_content) || nonEmptyString(record.reasoning); + const reasoningText = nonEmptyString(record.reasoning_text); + const thinking = nonEmptyString(record.thinking); + const thought = nonEmptyString(record.thought); + const details = extractReasoningDetailsText(record); + + const unsupported = reasoningText || thinking || thought || details; + const any = readable || unsupported; + + const hasUnsupportedSignal = !!( + !readable && + (reasoningText || + thinking || + thought || + (Array.isArray(record.reasoning_details) && record.reasoning_details.length > 0)) + ); + const hasAnySignal = !!any; + + return { readable, unsupported, any, hasUnsupportedSignal, hasAnySignal }; +} + +/** Back-compat wrappers for existing callers - delegate to consolidated extractor. */ +export function getReadableReasoningValue(value: unknown): string { + return extractReasoningFields(value).readable; } export function getUnsupportedReasoningValue(value: unknown): string { - const record = asReasoningRecord(value); - return ( - nonEmptyString(record.reasoning_text) || - nonEmptyString(record.thinking) || - nonEmptyString(record.thought) || - extractReasoningDetailsText(record) - ); + return extractReasoningFields(value).unsupported; } export function getAnyReasoningValue(value: unknown): string { - return getReadableReasoningValue(value) || getUnsupportedReasoningValue(value); + return extractReasoningFields(value).any; } export function hasUnsupportedReasoningSignal(value: unknown): boolean { - const record = asReasoningRecord(value); - return Boolean( - !getReadableReasoningValue(record) && - (nonEmptyString(record.reasoning_text) || - nonEmptyString(record.thinking) || - nonEmptyString(record.thought) || - (Array.isArray(record.reasoning_details) && record.reasoning_details.length > 0)) - ); + return extractReasoningFields(value).hasUnsupportedSignal; } export function hasAnyReasoningSignal(value: unknown): boolean { - const record = asReasoningRecord(value); - return Boolean( - getReadableReasoningValue(record) || - nonEmptyString(record.reasoning_text) || - nonEmptyString(record.thinking) || - nonEmptyString(record.thought) || - (Array.isArray(record.reasoning_details) && record.reasoning_details.length > 0) - ); + return extractReasoningFields(value).hasAnySignal; } const STRIPPABLE_REASONING_FIELDS = [ diff --git a/open-sse/utils/registeredEffortVariants.ts b/open-sse/utils/registeredEffortVariants.ts index 06e2d5dfc6..2cef8d6721 100644 --- a/open-sse/utils/registeredEffortVariants.ts +++ b/open-sse/utils/registeredEffortVariants.ts @@ -14,10 +14,9 @@ export function getRegisteredProviderEffortBaseModelId( modelId: string ): string | null { const providerModels = getProviderModels(providerId); + const registeredVariant = providerModels.find((candidate) => candidate.id === modelId); - if (!providerModels.some((candidate) => candidate.id === modelId)) { - return null; - } + if (!registeredVariant) return null; for (const effort of REGISTERED_EFFORT_SUFFIXES) { const suffix = `-${effort}`; @@ -25,7 +24,15 @@ export function getRegisteredProviderEffortBaseModelId( const baseModelId = modelId.slice(0, -suffix.length); - return providerModels.some((candidate) => candidate.id === baseModelId) ? baseModelId : null; + if (providerModels.some((candidate) => candidate.id === baseModelId)) return baseModelId; + + // Curated providers may intentionally expose only useful variants while the + // authoritative live catalog exposes their unsuffixed wire model. The registry + // declaration is the proof; never infer this relationship from spelling alone. + const declaredLiveBase = registeredVariant.liveCatalogIds?.find( + (candidate) => candidate === baseModelId || !candidate.endsWith(`-${effort}`) + ); + return declaredLiveBase ?? null; } return null; diff --git a/open-sse/utils/resourcePressure.ts b/open-sse/utils/resourcePressure.ts index acef067a1e..f3ba7f77d9 100644 --- a/open-sse/utils/resourcePressure.ts +++ b/open-sse/utils/resourcePressure.ts @@ -67,9 +67,47 @@ function requireDuration(name: string, value: number): number { return value; } -function buildCriticalGuard(reason: PressureReason): ResourcePressureGuardResult { +/** + * Human-readable key=value detail appended to the rejection log line. Every + * rejection (immediate heap trip AND cached-critical-state reuse) goes + * through here, so this is the one place that needs the actual numbers — + * the bare reason code alone ("psi_some") gives an operator nothing to act + * on when deciding whether the guard is mistuned vs. genuinely saturated. + */ +function formatPressureDetail(detail: Record): string { + return Object.entries(detail) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${value ?? "null"}`) + .join(" "); +} + +/** Builds buildCriticalGuard's detail object for the cached-critical-state + * reuse path in check() -- pulled out of check() itself so that function's + * own cyclomatic complexity stays under the ratchet, not because this needs + * to be reused anywhere else. */ +function describeCachedPressure(params: { + signals: ResourceSignals | null; + recoveryStreak: number; + cacheAgeMs: number; +}): Record { + const cgroup = params.signals?.cgroup; + return { + psiSomeAvg10: params.signals?.psi?.someAvg10 ?? null, + psiFullAvg10: params.signals?.psi?.fullAvg10 ?? null, + cgroupCurrentMb: cgroup?.currentBytes ? Math.round(cgroup.currentBytes / MB) : null, + cgroupMaxMb: cgroup?.maxBytes ? Math.round(cgroup.maxBytes / MB) : null, + recoveryStreak: params.recoveryStreak, + sampleAgeMs: params.cacheAgeMs, + }; +} + +function buildCriticalGuard( + reason: PressureReason, + detail: Record = {} +): ResourcePressureGuardResult { + const detailText = formatPressureDetail(detail); console.warn( - `[resourcePressure] critical pressure guard tripped (reason=${reason}); returning 503` + `[resourcePressure] critical pressure guard tripped (reason=${reason}${detailText ? " " + detailText : ""}); returning 503` ); return { success: false, @@ -97,7 +135,10 @@ function immediateHeapGuard( if (thresholdMb == null) return null; const guard = checkHeapPressureGuard(heapUsedMb, thresholdMb); if (!guard) return null; - return buildCriticalGuard("v8_heap_absolute"); + return buildCriticalGuard("v8_heap_absolute", { + heapUsedMb: Math.round(heapUsedMb), + thresholdMb: Math.round(thresholdMb), + }); } export function createResourcePressureRuntime( @@ -192,9 +233,17 @@ export function createResourcePressureRuntime( return immediate; } const cacheAge = lastSignals ? Math.max(0, now - lastRefreshAtMs) : Number.POSITIVE_INFINITY; - return cacheAge <= maxStaleMs && state.severity === "critical" - ? buildCriticalGuard(state.reason) - : null; + if (cacheAge > maxStaleMs || state.severity !== "critical") { + return null; + } + return buildCriticalGuard( + state.reason, + describeCachedPressure({ + signals: lastSignals, + recoveryStreak: state.recoveryStreak, + cacheAgeMs: cacheAge, + }) + ); }, getObservation: () => ({ signals: lastSignals, state }), whenRefreshSettled: async () => { diff --git a/open-sse/utils/resourcePressurePolicy.ts b/open-sse/utils/resourcePressurePolicy.ts index 447a084b61..3887bf9bbc 100644 --- a/open-sse/utils/resourcePressurePolicy.ts +++ b/open-sse/utils/resourcePressurePolicy.ts @@ -74,12 +74,30 @@ export const DEFAULT_RESOURCE_PRESSURE_THRESHOLDS: ResourcePressureThresholds = highRatio: 0.85, criticalRatio: 0.92, recoveryRatio: 0.75, - highPsiAvg10: 20, - criticalPsiAvg10: 40, - recoveryPsiAvg10: 10, + // Bumped 50% (20/40/10 -> 30/60/15): /proc/pressure/memory reflects + // HOST-wide PSI, not this process's own cgroup pressure (confirmed by + // comparing /proc/pressure/memory against /sys/fs/cgroup/memory.pressure + // from inside a running container -- the two differ). On a shared host + // running many unrelated workloads, host-wide memory contention from + // OTHER processes was tripping this guard even while OmniRoute's own + // usage stayed trivial. The ratio-based thresholds above stay untouched + // -- they're this process's own real OOM safety margin and unaffected by + // noisy neighbors. + highPsiAvg10: 30, + criticalPsiAvg10: 60, + recoveryPsiAvg10: 15, sustainedSamplesHigh: 2, sustainedSamplesCritical: 2, - sustainedSamplesRecovery: 3, + // PSI's own avg10 is a kernel-computed 10s rolling average, so it already + // lags real recovery by design -- requiring 3 consecutive samples *on top* + // of that (at the ~1s default sample cadence) stacked another ~2-3s of + // guard-still-shedding time after the process was actually fine again. + // isRecovered() already requires every tracked ratio/PSI value to clear + // the separate, more conservative recoveryRatio/recoveryPsiAvg10 + // thresholds (not just dip under the critical ones), so a single clean + // sample is real signal, not noise -- the streak requirement was adding + // redundant delay on top of an already-conservative bar. + sustainedSamplesRecovery: 1, heapAbsoluteThresholdMb: null, }; diff --git a/open-sse/utils/responsesStreamHelpers.ts b/open-sse/utils/responsesStreamHelpers.ts index a2cba80fc1..f77b2a0c53 100644 --- a/open-sse/utils/responsesStreamHelpers.ts +++ b/open-sse/utils/responsesStreamHelpers.ts @@ -98,25 +98,29 @@ function buildResponsesOutputItemKey(item: unknown): string | null { return `${type}:${id}:${callId}:${outputIndex}:${name}`; } +// Module-level Set reused across calls to avoid allocation per event +const _seenResponsesKeys = new Set(); + export function pushUniqueResponsesOutputItems(target: unknown[], items: readonly unknown[]) { - const seen = new Set(); + // Clear the reused Set instead of allocating new one + _seenResponsesKeys.clear(); for (const existingItem of target) { const key = buildResponsesOutputItemKey(existingItem); if (key) { - seen.add(key); + _seenResponsesKeys.add(key); } } for (const item of items) { const key = buildResponsesOutputItemKey(item); - if (key && seen.has(key)) { + if (key && _seenResponsesKeys.has(key)) { continue; } target.push(item); if (key) { - seen.add(key); + _seenResponsesKeys.add(key); } } } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index dd37eda217..d33cc8a526 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -178,6 +178,8 @@ type StreamOptions = { * codex-compatible `namespace` + `name` fields. */ requestToolIdentityMap?: Map | null; + /** High water mark for the TransformStream internal buffer (default: 16384) */ + highWaterMark?: number; }; type TranslateState = ReturnType & { @@ -1173,6 +1175,8 @@ export function createSSEStream(options: StreamOptions = {}) { } }; + const highWaterMark = options.highWaterMark ?? 16384; + return new TransformStream( { start(controller) { @@ -2992,8 +2996,8 @@ export function createSSEStream(options: StreamOptions = {}) { clearIdleTimer(); }, }, - { highWaterMark: 16384 }, - { highWaterMark: 16384 } + { highWaterMark }, + { highWaterMark } ); } @@ -3015,7 +3019,8 @@ export function createSSETransformStreamWithLogger( copilotCompatibleReasoning = false, suppressThinkClose = false, customToolNames: ReadonlySet = new Set(), - requestToolIdentityMap: Map | null = null + requestToolIdentityMap: Map | null = null, + highWaterMark?: number ) { return createSSEStream({ mode: STREAM_MODE.TRANSLATE, @@ -3034,6 +3039,7 @@ export function createSSETransformStreamWithLogger( suppressThinkClose, customToolNames, requestToolIdentityMap, + highWaterMark, }); } @@ -3048,7 +3054,8 @@ export function createPassthroughStreamWithLogger( apiKeyInfo: unknown = null, onFailure: ((payload: StreamFailurePayload) => boolean | void | Promise) | null = null, clientResponseFormat: string | null = null, - requestToolIdentityMap: Map | null = null + requestToolIdentityMap: Map | null = null, + highWaterMark?: number ) { return createSSEStream({ mode: STREAM_MODE.PASSTHROUGH, @@ -3063,6 +3070,7 @@ export function createPassthroughStreamWithLogger( onFailure, clientResponseFormat, requestToolIdentityMap, + highWaterMark, }); } diff --git a/open-sse/utils/streamHandler.ts b/open-sse/utils/streamHandler.ts index dbcc439eef..7776f2e5e9 100644 --- a/open-sse/utils/streamHandler.ts +++ b/open-sse/utils/streamHandler.ts @@ -629,7 +629,11 @@ function resolveSilentCloseOutcome(input: { return null; } -export function createDisconnectAwareStream(transformStream, streamController) { +export function createDisconnectAwareStream( + transformStream, + streamController, + options: { highWaterMark?: number } = {} +) { const reader = transformStream.readable.getReader(); const writer = transformStream.writable.getWriter(); const terminalDecoder = new TextDecoder(); @@ -697,6 +701,8 @@ export function createDisconnectAwareStream(transformStream, streamController) { } }; + const highWaterMark = options.highWaterMark ?? 16384; + return new ReadableStream( { async pull(controller) { @@ -818,7 +824,7 @@ export function createDisconnectAwareStream(transformStream, streamController) { await Promise.allSettled([reader.cancel(reason), writer.abort(reason)]); }, }, - { highWaterMark: 16384 } + { highWaterMark } ); } @@ -845,7 +851,7 @@ export function pipeWithDisconnect( providerResponse: Response, transformStream: TransformStream, streamController: StreamController, - opts: { stallTimeoutMs?: number } = {} + opts: { stallTimeoutMs?: number; highWaterMark?: number } = {} ) { const stallTimeoutMs = opts.stallTimeoutMs ?? DEFAULT_STREAM_STALL_TIMEOUT_MS; @@ -854,7 +860,8 @@ export function pipeWithDisconnect( const transformedBody = providerResponse.body.pipeThrough(transformStream); return createDisconnectAwareStream( { readable: transformedBody, writable: createNoopAbortWritable() }, - streamController + streamController, + { highWaterMark: opts.highWaterMark } ); } @@ -956,6 +963,7 @@ export function pipeWithDisconnect( .pipeThrough(transformStream); return createDisconnectAwareStream( { readable: transformedBody, writable: createNoopAbortWritable() }, - wrappedController + wrappedController, + { highWaterMark: opts.highWaterMark } ); } diff --git a/open-sse/utils/streamHelpers.ts b/open-sse/utils/streamHelpers.ts index db8c656d1d..39aafbacf9 100644 --- a/open-sse/utils/streamHelpers.ts +++ b/open-sse/utils/streamHelpers.ts @@ -70,6 +70,13 @@ function isRecord(value: unknown): value is Record { const ANSI_ESCAPE_RE = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[A-Z\[\]\\^_`])|[\x00-\x08\x0b\x0c\x0e-\x1f]/g; +// Pre-compiled regex constants for hot-path SSE processing (avoid per-call compilation) +const CR_STRIP_RE = /\r$/; +const SSE_FIELD_RE = /^(?:event:|id:|retry:|:)/i; +const SSE_EVENT_RE = /^event:\s*(.+)$/i; +const SSE_ID_RETRY_RE = /^(?::|id:|retry:)/i; +const SSE_EVENT_ONLY_RE = /^event:/i; + /** * Strip ANSI/VT100 escape sequences (and stray C0 controls) from a string. * Non-string inputs (null/undefined) are returned unchanged. Preserves \t \n \r. @@ -125,7 +132,7 @@ export function parseSSELine(line: string): SSEJsonPayload | null { } function extractSseDataLine(line: string): string | null { - const trimmed = stripAnsiCodes(line.trimStart().replace(/\r$/, "")); + const trimmed = stripAnsiCodes(line.trimStart().replace(CR_STRIP_RE, "")); if (!trimmed.startsWith("data:")) return null; return trimmed.slice(5).trimStart(); } @@ -192,12 +199,12 @@ export function createSSEDataLineNormalizer(): SSEDataLineNormalizer { normalize(lines: string[]) { const output: string[] = []; for (const line of lines) { - const normalizedLine = line.replace(/\r$/, ""); + const normalizedLine = line.replace(CR_STRIP_RE, ""); const trimmed = normalizedLine.trim(); if ( trimmed && - /^(?:event:|id:|retry:|:)/i.test(trimmed) && + SSE_FIELD_RE.test(trimmed) && hasSelfDescribingPendingDataPayload() ) { flush(output); @@ -235,7 +242,7 @@ export function createSSEEventPrefixBuffer(options?: { forwardEvent?: boolean }) }, eventType() { for (let i = lines.length - 1; i >= 0; i--) { - const match = lines[i].trim().match(/^event:\s*(.+)$/i); + const match = lines[i].trim().match(SSE_EVENT_RE); if (match) return match[1].trim(); } return ""; @@ -251,10 +258,10 @@ export function createSSEEventPrefixBuffer(options?: { forwardEvent?: boolean }) // `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; + if (SSE_ID_RETRY_RE.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; + if (SSE_EVENT_ONLY_RE.test(trimmed) && !forwardEvent) return; lines.push(line); emitted = false; }, diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index c7aab45b7a..f0353ce0f5 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -885,8 +885,26 @@ export function compactStructuredStreamPayload(payload: unknown): unknown { }; } +// Live incident (2026-09-02): a reasoning-heavy response streams reasoning +// token-by-token as hundreds to thousands of tiny SSE deltas BEFORE the real +// output/tool_calls ever arrive. At the old defaults (200 events / 48KB) the +// cap was routinely exhausted during the reasoning phase alone, dropping the +// completion event entirely -- measured live: ~22% of a sample of recent +// successful responses hit this. For a caller with no `format` (no live +// reducer -- see the CollectorOptions.format doc comment), the logged +// summary is reconstructed from getEvents() (open-sse/utils/stream.ts), so a +// dropped completion event produced a served-successfully response logged +// with status "in_progress" and empty output -- which +// src/lib/db/responsesContinuationStore.ts then had nothing real to +// reconstruct a later continuation turn from (see its own fail-closed fix, +// 2026-09-02). Raising the cap doesn't eliminate the class of bug for an +// arbitrarily long stream, but it removes it as a routine, everyday failure; +// the format-driven live reducer (used by providerPayloadCollector, an +// analogous prior fix) is the cap-independent fix and remains the deeper +// follow-up for a caller that still wants build()'s summary correct beyond +// any fixed cap. export function createStructuredSSECollector(options: CollectorOptions = {}) { - const { maxEvents = 200, maxBytes = 49152, stage, format, fallbackModel } = options; + const { maxEvents = 2000, maxBytes = 524288, stage, format, fallbackModel } = options; const events: StructuredSSEEvent[] = []; let usedBytes = 0; let droppedEvents = 0; diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 1b605acc0c..25a1a20d8b 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -680,10 +680,29 @@ export function isEmptyUsage(usage: unknown): boolean { /** * Extract usage from supported formats (Claude, OpenAI, Gemini, Responses API) + * Fast-path: return early for chunks without any usage-related fields. + * Most streaming chunks (content deltas) have no usage — avoids property checks. */ export function extractUsage(chunk: UsagePayloadLike | null | undefined) { if (!chunk || typeof chunk !== "object") return null; + // Fast-path: check for any usage-like fields before doing full extraction + // Most chunks are content deltas with no usage — return null immediately. + const c = chunk as Record; + const response = c.response as Record | undefined; + const message = c.message as Record | undefined; + if ( + !c.type && + c.usage === undefined && + c.usageMetadata === undefined && + response?.usage === undefined && + response?.usageMetadata === undefined && + message?.usage === undefined && + c.done !== true + ) { + return null; + } + // Claude/Antigravity streaming: message_start event carries INPUT tokens // FIX #74: This event was not handled — input_tokens were being dropped // Structure: { type: "message_start", message: { usage: { input_tokens: N, output_tokens: 0 } } } diff --git a/package-lock.json b/package-lock.json index 270593d514..6f720491df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "lucide-react": "^1.33.0", "marked": "^18.0.11", "marked-terminal": "^7.3.0", - "material-symbols": "^0.46.0", + "material-symbols": "^0.47.0", "mermaid": "^11.17.2", "monaco-editor": "^0.56.0", "next": "16.3.3", @@ -62,7 +62,7 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.4.0", - "open": "^11.0.1", + "open": "^11.0.2", "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", @@ -85,7 +85,7 @@ "sql.js": "^1.14.2", "tailwind-merge": "^3.6.0", "tiktoken": "^1.0.22", - "tsx": "^4.23.12", + "tsx": "^4.23.13", "turndown": "7.2.4", "turndown-plugin-gfm": "1.0.2", "undici": "^8.10.0", @@ -94,7 +94,7 @@ "ws": "^8.21.3", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", - "zod": "^4.4.3", + "zod": "^4.5.4", "zustand": "^5.0.15" }, "bin": { @@ -148,7 +148,7 @@ "lint-staged": "^17.4.1", "lockfile-lint": "^5.0.1", "node-loader": "^2.1.0", - "opencode-ai": "1.18.23", + "opencode-ai": "1.18.25", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.9.6", "promptfoo": "^0.122.1", @@ -4578,29 +4578,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -14803,9 +14817,9 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", - "integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { @@ -15109,9 +15123,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", - "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", "dev": true, "license": "MIT", "engines": { @@ -16252,9 +16266,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -16612,9 +16626,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -16632,11 +16646,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -17086,9 +17100,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -19413,7 +19427,9 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" @@ -20004,9 +20020,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.375", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", - "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -21686,9 +21702,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -28336,9 +28352,9 @@ } }, "node_modules/material-symbols": { - "version": "0.46.0", - "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.46.0.tgz", - "integrity": "sha512-YxmTXwOhLOI6EupAwFfxFERbaDe61dG/tveOSy2HecndGKqvJ74WqXrrXLNWpIGDkk6TDpieuQPDS+hA7+z3Ig==", + "version": "0.47.0", + "resolved": "https://registry.npmjs.org/material-symbols/-/material-symbols-0.47.0.tgz", + "integrity": "sha512-/Wt7QSv5Hih8EFj9ySHnnF+NAGajTMRLkfajj0j4MNCY3FB2QhGRg5xtnBs5bUE63mzVo7J5XbbIDr0HeITpHg==", "license": "Apache-2.0" }, "node_modules/math-intrinsics": { @@ -30762,9 +30778,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -31385,16 +31401,16 @@ "optional": true }, "node_modules/open": { - "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==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.2.tgz", + "integrity": "sha512-RWqF+pBSkqecEvCKOn8QYhaNdRMJDZRIrlS/7rTDdLHaPcfXGCZ/h8zb413NfvdeAV0MR7T1yJcA34/q+CSm1Q==", "license": "MIT", "dependencies": { - "default-browser": "^5.4.0", + "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "powershell-utils": "^0.2.0", + "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" }, "engines": { @@ -31404,6 +31420,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open/node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openai": { "version": "6.46.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.46.0.tgz", @@ -31437,9 +31469,9 @@ } }, "node_modules/opencode-ai": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.23.tgz", - "integrity": "sha512-3NkT0XINL7d0HYkTyGV1SPChHXhvRgKqNaTgKRTGb0TXUWszXA7MW/y3zMZw29y1AQuUDAzRvVYmQ9KGRQhroA==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.25.tgz", + "integrity": "sha512-pS4RKJ9eKwU7Dp5G5pdj1rhMnpG5APixXzfTKNoFqv9aFVI36Rnza2jESvKifxyPZlsA65MQB03WCArY0EK6mg==", "cpu": [ "arm64", "x64" @@ -31456,24 +31488,24 @@ "opencode": "bin/opencode.exe" }, "optionalDependencies": { - "opencode-darwin-arm64": "1.18.23", - "opencode-darwin-x64": "1.18.23", - "opencode-darwin-x64-baseline": "1.18.23", - "opencode-linux-arm64": "1.18.23", - "opencode-linux-arm64-musl": "1.18.23", - "opencode-linux-x64": "1.18.23", - "opencode-linux-x64-baseline": "1.18.23", - "opencode-linux-x64-baseline-musl": "1.18.23", - "opencode-linux-x64-musl": "1.18.23", - "opencode-windows-arm64": "1.18.23", - "opencode-windows-x64": "1.18.23", - "opencode-windows-x64-baseline": "1.18.23" + "opencode-darwin-arm64": "1.18.25", + "opencode-darwin-x64": "1.18.25", + "opencode-darwin-x64-baseline": "1.18.25", + "opencode-linux-arm64": "1.18.25", + "opencode-linux-arm64-musl": "1.18.25", + "opencode-linux-x64": "1.18.25", + "opencode-linux-x64-baseline": "1.18.25", + "opencode-linux-x64-baseline-musl": "1.18.25", + "opencode-linux-x64-musl": "1.18.25", + "opencode-windows-arm64": "1.18.25", + "opencode-windows-x64": "1.18.25", + "opencode-windows-x64-baseline": "1.18.25" } }, "node_modules/opencode-darwin-arm64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.23.tgz", - "integrity": "sha512-QP9PjwpHtZoLVXw2WvUmPZecz7mWbQkT4t3K36B//fCaDG+zWa+SsztIeaW5azujNwwtUemLA5icE/zINng48Q==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.25.tgz", + "integrity": "sha512-W4dyMFtHBglWZ1SEooh3Ke9v1M9lv945Y58atb8e1yKII8YykJ8LknOFyKipYC028oPDO4IZc3GYGKbg9PCg2w==", "cpu": [ "arm64" ], @@ -31484,9 +31516,9 @@ ] }, "node_modules/opencode-darwin-x64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.23.tgz", - "integrity": "sha512-R9nWP3edz/0FnEfwmuxtiWBB7bS4NtZCyCffJyiMlrbwdDC+bIXYrWxWXVrzaP1mJujs6g2MAwTCUSx/qpBhDw==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.25.tgz", + "integrity": "sha512-YYKrfeUSJhD7hZl+yNmayS51sDwxiE9o5XwrfgYSSie6sOyHFc9Ei13VBkVU6T+IJHhFhTahOFAwSDxggrAnGA==", "cpu": [ "x64" ], @@ -31497,9 +31529,9 @@ ] }, "node_modules/opencode-darwin-x64-baseline": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.23.tgz", - "integrity": "sha512-QGx6I/nFYur7qJ/Nx2L3fC4XYQt44cyDsm7p8twNA+cdjGX3ndnPbMdAl5ikdZyAfSMUGYK8VWY2JMxv0rmfjw==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.25.tgz", + "integrity": "sha512-rRgTaoTeIN2diL1e1HGZ48Zh4ynMDEB1jYjD76LaFUVzMwakEY1i7NvG8e/rbjRMDkgXIr6TwzCtIMcMOpLQMA==", "cpu": [ "x64" ], @@ -31510,9 +31542,9 @@ ] }, "node_modules/opencode-linux-arm64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.23.tgz", - "integrity": "sha512-g1zDFhuE9FOYwjSGderlu69wfd4GQzS0xsDiIY11QUciuBmM6DrHqLvmhlLFsUVHYVnCPY1YeN1pq5ewE3x72Q==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.25.tgz", + "integrity": "sha512-PMvcpFpha3yAhaVCC0QbegHPxsEZ0FuQf+52PXvqQut1r3w1l1Pilor9tUA7TyCRa4UokACI90nTmKmtMnQBag==", "cpu": [ "arm64" ], @@ -31523,9 +31555,9 @@ ] }, "node_modules/opencode-linux-arm64-musl": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.23.tgz", - "integrity": "sha512-VyDkzUJfJgkx9h9RhazTW9xeTgSXBmVFPblsbPGBW9tR612f6gjQxfOfu4cpHnQHK1qjuW9ClzLKHWqv3EcJTA==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.25.tgz", + "integrity": "sha512-IwIPKmNwIjLshlSgjoRLKFwxxiLpZ5Y0zjv6r456RQtJKK62IbYGXkCm3AiSZ/lqGsu3XF+xn/Xza29ivgpgcg==", "cpu": [ "arm64" ], @@ -31539,9 +31571,9 @@ ] }, "node_modules/opencode-linux-x64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.23.tgz", - "integrity": "sha512-5x9d1Cm/YtqzR6lAlNbgVprTQ3R3hx7qGTWCzm5l5u6lBNkhYTrhy2s8k25dxKKuxqZ9Kngqz9JYWvSVHy2Lmw==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.25.tgz", + "integrity": "sha512-bdRSJ6gbK/EnLNWxROOQYXFXiUeqeFxGz8DIO8LCqnii99A2OWFAyZ3Da5gpvfT1Yrp9/lYL55n/tM3ale5smg==", "cpu": [ "x64" ], @@ -31552,9 +31584,9 @@ ] }, "node_modules/opencode-linux-x64-baseline": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.23.tgz", - "integrity": "sha512-yUhBOXfTQour2JCdAkwD3DDqSnyxB0grefwdPqEhYmJHIkYxfJIIzyy6V//pyouvkE0XMouFtiuZXw8S6Wo0iQ==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.25.tgz", + "integrity": "sha512-+b0w7XyHx0XPQWHBk2JymXbXnyZQ2PjIPuu4a4QJgSUqGuGz1L2flA3wgpZVAWFUhrEIr9DFhBk3AkKKNgMuRw==", "cpu": [ "x64" ], @@ -31565,9 +31597,9 @@ ] }, "node_modules/opencode-linux-x64-baseline-musl": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.23.tgz", - "integrity": "sha512-c1DPxauhzAurlIBhJBr/rokDpc65l084T4qTl36gDDT9Xzc/Nk5Q5yMDaPm1DDI3WeHKDt11MDlxT5AjQW5gtw==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.25.tgz", + "integrity": "sha512-E2JUeOOSXPbG1cNOzxnqjqkd0a3+oFmwkbJe6bZ308CFgLWBFfVh0fF42HTCEqfK+yYbidpEkQuEkUgxq/11IA==", "cpu": [ "x64" ], @@ -31581,9 +31613,9 @@ ] }, "node_modules/opencode-linux-x64-musl": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.23.tgz", - "integrity": "sha512-t/5mlnTBZKdZpqKHwdwxlWqGakntauvMSmXtyJc17M7XJRmZaaGHtNSaSefbbYFIL4agoQCXTvIkhhyxOvr7zQ==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.25.tgz", + "integrity": "sha512-W15qTNDz1fsTzs1SkE6bB/gpIDBF3rwDbewUKdbyXD3dVs6umyugOql1T4u9n/gqWa/Z/VDURbn39VsejeSdbQ==", "cpu": [ "x64" ], @@ -31597,9 +31629,9 @@ ] }, "node_modules/opencode-windows-arm64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.23.tgz", - "integrity": "sha512-QtJQcLU0yPz6on3jjks3f/EHgZuIDFw7FvAKu3wsHhL09NYDh7GczfRXDPRHa3NgqnU8dkB8p9mhqgaPRPogoQ==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.25.tgz", + "integrity": "sha512-GFp74pProoPwqktHMf+9wQ8fza1RvFt0RG0iRtTQnJ4VWVY62qEeVuJkH6ki9QXS270HXyqtxvF8AuHQzuVZlA==", "cpu": [ "arm64" ], @@ -31610,9 +31642,9 @@ ] }, "node_modules/opencode-windows-x64": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.23.tgz", - "integrity": "sha512-mMaIITuXzkNfjdcYL8uZaZuMDjulFyH/UCq9bxblam2mUZf9uWisoi5J6CXFsS/mkN7CZfTAt6PttSp4n3PH4g==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.25.tgz", + "integrity": "sha512-xW5wtSxWYbI7DcmQWMlNWIiDBdMJON1vDiEmVWo88R9tT/PaahOhWKgp7FoWDqJKf89jS3ZIzkqnkU3F2dio7A==", "cpu": [ "x64" ], @@ -31623,9 +31655,9 @@ ] }, "node_modules/opencode-windows-x64-baseline": { - "version": "1.18.23", - "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.23.tgz", - "integrity": "sha512-AqXsTKaPcDx3rrid5bLUwJbQ/3vr9rJ6fvOStIznTzwrbOgP8wy5G4jCoIzu6KB/WxGx/d1MrV4cGaJ73qnjBA==", + "version": "1.18.25", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.25.tgz", + "integrity": "sha512-/28bGRQwT+2JdGbtGaNr95tstgysiULEXtcvgNg7yLDxitqmSVgd8V8XRGS0UWDdfiWWMND9A9T5EAsbF1/xDQ==", "cpu": [ "x64" ], @@ -32966,9 +32998,9 @@ } }, "node_modules/powershell-utils": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.0.tgz", - "integrity": "sha512-ZlsFlG7MtSFCoc5xreOvBAozCJ6Pf06opgJjh9ONEv418xpZSAzNjstD36C6+JwOnfSqOW/9uDkqKjezTdxZhw==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", "license": "MIT", "engines": { "node": ">=20" @@ -34020,12 +34052,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -35725,14 +35758,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -35744,13 +35777,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -37629,9 +37662,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.12", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", - "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -38338,9 +38371,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -39902,9 +39935,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index f019c45e90..4925d88658 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.51", - "description": "Unified AI router with 355 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 356 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", @@ -95,6 +95,7 @@ "bench:compression": "bun scripts/compression/benchmark.ts", "bench:heap-body": "node --expose-gc --import tsx/esm scripts/perf/request-body-heap.ts", "bench:routing-events": "node --import tsx/esm scripts/perf/routing-events-bench.ts", + "bench:highwatermark": "node --import tsx/esm scripts/perf/benchmark-highwatermark.ts", "eval:compression": "node --import tsx scripts/compression-eval/index.ts", "eval:router": "node --import tsx scripts/router-eval/index.ts", "eval:router:compare": "node --import tsx scripts/router-eval/compare.ts", @@ -150,7 +151,8 @@ "check:router-eval": "node --import tsx scripts/check/check-router-eval-regression.ts", "check:doc-links": "node scripts/check/check-doc-links.mjs", "check:fabricated-docs": "node scripts/check/check-fabricated-docs.mjs --strict", - "check:docs-all": "npm run check:docs-sync && npm run check:docs-counts && npm run check:env-doc-sync && npm run check:deprecated-versions && npm run check:doc-links && npm run check:fabricated-docs", + "check:docs-frontmatter": "node scripts/check/check-docs-frontmatter.mjs", + "check:docs-all": "npm run check:docs-sync && npm run check:docs-frontmatter && npm run check:docs-counts && npm run check:env-doc-sync && npm run check:deprecated-versions && npm run check:doc-links && npm run check:fabricated-docs", "docs:render-diagrams": "node scripts/docs/render-diagrams.mjs", "i18n:run": "node scripts/i18n/run-translation.mjs", "i18n:run:dry": "node scripts/i18n/run-translation.mjs --dry-run", @@ -319,7 +321,7 @@ "lucide-react": "^1.33.0", "marked": "^18.0.11", "marked-terminal": "^7.3.0", - "material-symbols": "^0.46.0", + "material-symbols": "^0.47.0", "mermaid": "^11.17.2", "monaco-editor": "^0.56.0", "next": "16.3.3", @@ -327,7 +329,7 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.4.0", - "open": "^11.0.1", + "open": "^11.0.2", "ora": "^9.4.1", "parse5": "^8.0.1", "pino": "^10.3.1", @@ -350,7 +352,7 @@ "sql.js": "^1.14.2", "tailwind-merge": "^3.6.0", "tiktoken": "^1.0.22", - "tsx": "^4.23.12", + "tsx": "^4.23.13", "turndown": "7.2.4", "turndown-plugin-gfm": "1.0.2", "undici": "^8.10.0", @@ -359,7 +361,7 @@ "ws": "^8.21.3", "xxhash-wasm": "^1.1.0", "yazl": "^3.3.1", - "zod": "^4.4.3", + "zod": "^4.5.4", "zustand": "^5.0.15" }, "optionalDependencies": { @@ -419,7 +421,7 @@ "lint-staged": "^17.4.1", "lockfile-lint": "^5.0.1", "node-loader": "^2.1.0", - "opencode-ai": "1.18.23", + "opencode-ai": "1.18.25", "playwright-ctrf-json-reporter": "^0.0.29", "prettier": "^3.9.6", "promptfoo": "^0.122.1", @@ -467,7 +469,7 @@ "sharp": "^0.35.4", "postcss": "^8.5.18", "ip-address": "^10.3.1", - "qs": "^6.15.2", + "qs": "^6.16.0", "uuid": "^14.0.2", "form-data": "^4.0.6", "vite": "^8.0.16", @@ -475,7 +477,7 @@ "@babel/core": "^7.29.6", "hono": "^4.12.34", "@hono/node-server": "^2.0.5", - "fast-uri": "^3.1.5", + "fast-uri": "^3.1.7", "body-parser": "^2.3.0", "@yarnpkg/parsers": { "js-yaml": "^4.3.1" diff --git a/public/images/tier-flow-dark.svg b/public/images/tier-flow-dark.svg index 1cf2589812..8f7fcde48f 100644 --- a/public/images/tier-flow-dark.svg +++ b/public/images/tier-flow-dark.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 355 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 356 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 355 providers + Never stop building — automatic zero-config failover across 356 providers diff --git a/public/images/tier-flow-light.svg b/public/images/tier-flow-light.svg index cd79d47e3b..5ad3a108f7 100644 --- a/public/images/tier-flow-light.svg +++ b/public/images/tier-flow-light.svg @@ -1,6 +1,6 @@ - + OmniRoute 4-tier fallback - OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 355 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. + OmniRoute 4-tier fallback: your IDE or CLI calls one local endpoint and the OmniRoute Smart Router fails over across 356 providers in 4 tiers — Tier 1 Subscription, Tier 2 API, Tier 3 Cheap, Tier 4 Free. @@ -15,7 +15,7 @@ OmniRoute 4-tier fallback - Never stop building — automatic zero-config failover across 355 providers + Never stop building — automatic zero-config failover across 356 providers diff --git a/scripts/check/check-docs-frontmatter.mjs b/scripts/check/check-docs-frontmatter.mjs new file mode 100644 index 0000000000..a00751f880 --- /dev/null +++ b/scripts/check/check-docs-frontmatter.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Validates the frontmatter of every Markdown file that fumadocs-mdx compiles. + * + * Why this gate exists: `source.config.ts` feeds `docs/**` globs to + * `defineDocs()`, and fumadocs' default frontmatter schema REQUIRES a `title` + * string. A doc added without frontmatter does not fail any docs gate — it + * fails the **production build** with a generic Turbopack error + * (`[MDX] invalid frontmatter … title: Invalid input: expected string, + * received undefined`), which then cascades into `check:pack-artifact` and the + * tarball boot-smoke. That is exactly how #12478 turned the release branch red + * (base-red #12581): one new reference doc, no frontmatter, three failing + * gates and an unbuildable branch. + * + * Catching it here costs milliseconds instead of a full Next build. + * + * The globs are read from `source.config.ts` rather than duplicated, so adding + * a new docs directory there cannot silently escape this check. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const CONFIG_PATH = path.join(ROOT, "source.config.ts"); + +/** Extract the `files: [...]` globs declared in source.config.ts. */ +function readConfiguredGlobs() { + const src = fs.readFileSync(CONFIG_PATH, "utf-8"); + const block = src.match(/files\s*:\s*\[([\s\S]*?)\]/); + if (!block) { + console.error( + "[docs-frontmatter] FAIL — could not locate the `files:` globs in source.config.ts" + ); + process.exit(1); + } + const globs = [...block[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]); + if (globs.length === 0) { + console.error("[docs-frontmatter] FAIL — source.config.ts declares no doc globs"); + process.exit(1); + } + return globs; +} + +/** "./reference/**\/*.md" -> the directory under docs/ it covers. */ +function globToDir(glob) { + const cleaned = glob.replace(/^\.\//, ""); + const dir = cleaned.split("/**")[0]; + return path.join(ROOT, "docs", dir); +} + +function walkMarkdown(dir) { + if (!fs.existsSync(dir)) return []; + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkMarkdown(full)); + else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full); + } + return out; +} + +const violations = []; +const files = [...new Set(readConfiguredGlobs().flatMap((g) => walkMarkdown(globToDir(g))))]; + +for (const file of files) { + const rel = path.relative(ROOT, file); + const text = fs.readFileSync(file, "utf-8"); + + if (!text.startsWith("---")) { + violations.push(`${rel}: no frontmatter block (fumadocs requires a \`title\`)`); + continue; + } + const end = text.indexOf("\n---", 3); + if (end === -1) { + violations.push(`${rel}: frontmatter block is never closed`); + continue; + } + const frontmatter = text.slice(3, end); + const title = frontmatter.match(/^\s*title\s*:\s*(.+)$/m); + if (!title) { + violations.push(`${rel}: frontmatter has no \`title\``); + } else if (title[1].trim().replace(/^["']|["']$/g, "") === "") { + violations.push(`${rel}: \`title\` is empty`); + } +} + +if (violations.length > 0) { + console.error( + `[docs-frontmatter] FAIL — ${violations.length} doc(s) would break the Next build:` + ); + for (const v of violations) console.error(` - ${v}`); + console.error( + "\nEvery Markdown file matched by source.config.ts is compiled by fumadocs-mdx and needs a\n" + + 'frontmatter block with a title, e.g.:\n\n---\ntitle: "Removed Providers"\nversion: 3.8.51\nlastUpdated: 2026-09-03\n---\n' + ); + process.exit(1); +} + +console.log( + `[docs-frontmatter] OK — ${files.length} compiled doc(s) carry a valid frontmatter title.` +); diff --git a/scripts/check/check-known-symbols.ts b/scripts/check/check-known-symbols.ts index e655c169bb..769afa5493 100644 --- a/scripts/check/check-known-symbols.ts +++ b/scripts/check/check-known-symbols.ts @@ -211,6 +211,8 @@ export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [ "antigravity:openai", "claude:gemini", "claude:openai", + // Naver CLOVA Studio Chat Completions v3 (native envelope, model in URL path). + "clova:openai", "cursor:openai", "gemini:claude", "gemini:openai", @@ -218,6 +220,7 @@ export const KNOWN_TRANSLATOR_PAIRS: readonly string[] = [ "openai-responses:openai", "openai:antigravity", "openai:claude", + "openai:clova", "openai:cursor", "openai:gemini", "openai:kiro", diff --git a/scripts/check/check-openapi-security-tiers.mjs b/scripts/check/check-openapi-security-tiers.mjs index 812e4a0c1b..89d17c0482 100644 --- a/scripts/check/check-openapi-security-tiers.mjs +++ b/scripts/check/check-openapi-security-tiers.mjs @@ -1,9 +1,23 @@ #!/usr/bin/env node /** * Cross-references openapi.yaml x-loopback-only / x-always-protected annotations - * against the compile-time constants in src/server/authz/routeGuard.ts. + * against the compile-time route-classification constants in + * src/server/authz/routeGuard.ts. * - * Fails if any YAML annotation disagrees with the routeGuard.ts constants. + * routeGuard classifies a loopback-only route through TWO mechanisms, and this + * checker must honor BOTH or it reports false positives (regression #12335): + * + * 1. LOCAL_ONLY_API_PREFIXES — flat string prefixes. One entry + * (VNC_ROUTE_PREFIX) is an imported const rather than a string literal, so + * it is resolved from its source module. + * 2. LOCAL_ONLY_API_PATTERNS — RegExp entries for spawn-capable routes whose + * dynamic path parameter sits BEFORE the gated segment (e.g. + * /api/providers/{id}/login), which a flat prefix cannot target without + * over-broadening the whole /api/providers/ subtree. + * + * A route is "covered" iff it matches a resolved prefix OR a pattern — exactly + * the `isLocalOnlyPath()` runtime contract. Fails if any YAML annotation + * disagrees with the routeGuard.ts constants. */ import fs from "node:fs"; @@ -13,34 +27,116 @@ import * as yaml from "js-yaml"; const ROOT = process.cwd(); const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts"); +const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8"); -function parseStringArray(match) { - if (!match) return []; - // Strip line comments before splitting — array entries in routeGuard.ts often - // carry inline `// T-XX:` annotations that would otherwise pollute the parsed tokens. - return match[1] - .replace(/\/\/[^\n]*/g, "") - .split(",") - .map((s) => s.trim().replace(/^["']|["']$/g, "")) - .filter(Boolean); +// Capture an exported array's body up to its closing `\n];`. Unlike a `[^\]]+` +// capture, this is immune to `]` characters inside comments or regex character +// classes (e.g. `[^/]`) — the exact footgun documented at routeGuard.ts's +// /api/oauth/cursor/auto-import entry, and the reason regex patterns could not +// be parsed at all before. +function extractArrayBody(name) { + const m = guardSrc.match( + new RegExp(`export const ${name}\\b[\\s\\S]*?=\\s*\\[([\\s\\S]*?)\\n\\];`) + ); + return m ? m[1] : null; } -const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8"); -const LOCAL_ONLY_PREFIXES = parseStringArray( - guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s) -); -const ALWAYS_PROTECTED_PATHS = parseStringArray( - guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s) -); +const stripLineComments = (s) => s.replace(/\/\/[^\n]*/g, ""); -if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) { - console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants"); +function resolveModule(spec) { + let base; + if (spec.startsWith("@/")) base = path.join(ROOT, "src", spec.slice(2)); + else if (spec.startsWith(".")) base = path.resolve(path.dirname(ROUTE_GUARD_PATH), spec); + else throw new Error(`openapi-security-tiers: unsupported import specifier '${spec}'`); + for (const cand of [base, `${base}.ts`, `${base}.mts`, path.join(base, "index.ts")]) { + if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; + } + throw new Error(`openapi-security-tiers: cannot resolve module '${spec}' (from ${base})`); +} + +// Resolve a bare identifier used inside a prefix array (e.g. VNC_ROUTE_PREFIX) +// to its string-literal value by following its import in routeGuard.ts. +function resolveIdentifier(ident) { + const imp = guardSrc.match( + new RegExp(`import\\s*(?:type\\s*)?\\{[^}]*\\b${ident}\\b[^}]*\\}\\s*from\\s*["']([^"']+)["']`) + ); + if (!imp) + throw new Error( + `openapi-security-tiers: '${ident}' used in a prefix array has no import in routeGuard.ts` + ); + const modSrc = fs.readFileSync(resolveModule(imp[1]), "utf-8"); + const lit = modSrc.match(new RegExp(`export const ${ident}\\s*=\\s*["']([^"']+)["']`)); + if (!lit) + throw new Error(`openapi-security-tiers: cannot resolve '${ident}' to a string literal`); + return lit[1]; +} + +// String prefixes: quoted entries pass through; bare identifiers are resolved. +function parsePrefixes(name) { + const body = extractArrayBody(name); + if (body == null) + throw new Error(`openapi-security-tiers: could not locate ${name} in routeGuard.ts`); + return stripLineComments(body) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((tok) => { + const unquoted = tok.replace(/^["']|["']$/g, ""); + return unquoted !== tok ? unquoted : resolveIdentifier(tok); + }); +} + +// RegExp patterns: one `/.../ ` literal per line. +function parsePatterns(name) { + const body = extractArrayBody(name); + if (body == null) + throw new Error(`openapi-security-tiers: could not locate ${name} in routeGuard.ts`); + const out = []; + for (const raw of body.split("\n")) { + const t = raw + .replace(/\/\/.*$/, "") + .trim() + .replace(/,\s*$/, "") + .trim(); + if (t.length > 2 && t.startsWith("/") && t.endsWith("/")) out.push(new RegExp(t.slice(1, -1))); + } + return out; +} + +const LOCAL_ONLY_PREFIXES = parsePrefixes("LOCAL_ONLY_API_PREFIXES"); +const LOCAL_ONLY_PATTERNS = parsePatterns("LOCAL_ONLY_API_PATTERNS"); +const ALWAYS_PROTECTED_PATHS = parsePrefixes("ALWAYS_PROTECTED_API_PATHS"); + +if ( + LOCAL_ONLY_PREFIXES.length === 0 || + LOCAL_ONLY_PATTERNS.length === 0 || + ALWAYS_PROTECTED_PATHS.length === 0 +) { + console.error( + `[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants ` + + `(prefixes=${LOCAL_ONLY_PREFIXES.length}, patterns=${LOCAL_ONLY_PATTERNS.length}, ` + + `alwaysProtected=${ALWAYS_PROTECTED_PATHS.length})` + ); process.exit(1); } +// OpenAPI template params ({id}, {sessionId}, …) → a concrete single non-slash +// segment, so pattern regexes written against resolved paths (`[^/]+`) match. +const concretize = (p) => p.replace(/\{[^}]+\}/g, "x"); + +const matchesPrefix = (concrete) => + LOCAL_ONLY_PREFIXES.some((prefix) => { + const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + return concrete === norm || concrete.startsWith(`${norm}/`); + }); + +function coveredByLocalOnly(pathStr) { + const concrete = concretize(pathStr); + return matchesPrefix(concrete) || LOCAL_ONLY_PATTERNS.some((re) => re.test(concrete)); +} + const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); const paths = raw.paths || {}; - const errors = []; for (const [pathStr, methods] of Object.entries(paths)) { @@ -48,17 +144,11 @@ for (const [pathStr, methods] of Object.entries(paths)) { for (const [method, spec] of Object.entries(methods)) { if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue; - if (spec["x-loopback-only"] === true) { - const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => { - const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; - return pathStr === norm || pathStr.startsWith(norm + "/"); - }); - if (!matchesPrefix) { - errors.push( - `${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` + - `LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]` - ); - } + if (spec["x-loopback-only"] === true && !coveredByLocalOnly(pathStr)) { + errors.push( + `${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` + + `LOCAL_ONLY_API_PREFIXES or LOCAL_ONLY_API_PATTERNS` + ); } if (spec["x-always-protected"] === true) { @@ -75,23 +165,13 @@ for (const [pathStr, methods] of Object.entries(paths)) { } } -// Reverse pass: every YAML path that falls under a LOCAL_ONLY prefix should -// carry `x-loopback-only: true` on every method, otherwise external API -// consumers have no signal that the route is loopback-restricted. Closes the -// "new spawn-capable route added without annotation" regression class. -// -// Currently reported as warnings (non-fatal) because the v3.8.4 release ships -// with a known annotation gap on /api/services/* and /api/cli-tools/runtime/* -// that will be patched in a follow-up doc-only PR. Promote to errors once the -// backlog is cleared. +// Reverse pass (non-fatal): every YAML path that falls under a LOCAL_ONLY prefix +// should carry `x-loopback-only`. Pattern-only routes are intentionally excluded +// — they are not "under" a broad prefix. Known annotation gaps stay warnings. const reverseWarnings = []; for (const [pathStr, methods] of Object.entries(paths)) { if (!methods || typeof methods !== "object") continue; - const fallsUnderLocalOnly = LOCAL_ONLY_PREFIXES.some((prefix) => { - const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; - return pathStr === norm || pathStr.startsWith(norm + "/"); - }); - if (!fallsUnderLocalOnly) continue; + if (!matchesPrefix(concretize(pathStr))) continue; for (const [method, spec] of Object.entries(methods)) { if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue; if (spec["x-loopback-only"] !== true) { @@ -105,7 +185,7 @@ for (const [pathStr, methods] of Object.entries(paths)) { if (reverseWarnings.length > 0) { console.warn( - `[openapi-security-tiers] WARN — ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal, follow-up doc PR):` + `[openapi-security-tiers] WARN — ${reverseWarnings.length} LOCAL_ONLY paths missing x-loopback-only annotation (non-fatal):` ); reverseWarnings.forEach((w) => console.warn(` - ${w}`)); } diff --git a/scripts/check/check-public-creds.mjs b/scripts/check/check-public-creds.mjs index 7e065705d0..62549001bb 100644 --- a/scripts/check/check-public-creds.mjs +++ b/scripts/check/check-public-creds.mjs @@ -90,14 +90,18 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/; // The MiniMax family was extracted from services/usage.ts into services/usage/minimax.ts // (god-file decomposition), so the FP moved with the getMiniMaxUsage signature. // -// open-sse/executors/zcodeProtocol.ts L302: `clientId: \`omniroute-${process.pid}\`` +// open-sse/executors/zcodeProtocol.ts L313: `clientId: \`omniroute-${process.pid}\`` // is the per-process identifier in the local ZCode app-server handshake. It is // generated from the process PID, is not an upstream OAuth/client credential, and // must remain visible in the wire contract. Frozen by file:line:value key. +// NOTE: the key includes the LINE, so any edit that shifts this statement breaks +// the gate twice over — a stale-entry error plus a "new violation" for the same +// literal. That is what happened here (L302 -> L313). Re-point the line; do not +// remove the entry. export const KNOWN_LITERAL_CREDS = new Set([ "open-sse/services/usage/minimax.ts:213:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature) "open-sse/services/usage/minimax.ts:213:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (getMiniMaxUsage signature) - "open-sse/executors/zcodeProtocol.ts:302:omniroute-${process.pid}", // local per-process ZCode handshake ID, not an upstream credential + "open-sse/executors/zcodeProtocol.ts:313:omniroute-${process.pid}", // local per-process ZCode handshake ID, not an upstream credential ]); /** diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 8f67b95018..1a666b3634 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -761,15 +761,21 @@ function CombosPageContent() { const [proxyConfig, setProxyConfig] = useState(null); const { comboProxyAssignedIds, fetchComboProxyAssignments } = useComboProxyAssignments(); const [providerNodes, setProviderNodes] = useState([]); - const [showUsageGuide, setShowUsageGuide] = useState(() => { - // Lazy initializer instead of a mount effect (react-hooks/set-state-in-effect). + // SSR has no localStorage, so a lazy initializer reading it here returns a + // different value server-side (always "not dismissed") than the client's + // real stored value -- exactly the kind of source React's hydration + // mismatch check is built to catch, and in dev mode a mismatch forces a + // full client-only re-render of this tree, discarding whatever the fetch + // effects below had already populated. Start with the SSR-safe default on + // both passes and correct it client-only, after hydration, in an effect. + const [showUsageGuide, setShowUsageGuide] = useState(true); + useEffect(() => { try { - return globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1"; + setShowUsageGuide(globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1"); } catch { // Ignore storage access errors (privacy mode / restricted environments) - return true; } - }); + }, []); const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState(""); const [creatingKimiPreset, setCreatingKimiPreset] = useState(false); const [comboDragIndex, setComboDragIndex] = useState(null); diff --git a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx index 4d7b506171..31bf43fff6 100644 --- a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx +++ b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx @@ -3,12 +3,13 @@ // CompressionPanel — the single-source engine-grid UI for compression. // // Renders the master on/off switch, one row per catalog engine (on/off + level + -// link to its detail page), the cavemanOutput intensity row, the mcpAccessibility -// toggle (its own endpoint / separate store), a read-only derived-pipeline preview, -// and the general settings (auto-trigger tokens + preserve-system-prompt). +// link to its detail page), the adaptive context-budget dial, the cavemanOutput +// intensity row, the mcpAccessibility toggle (its own endpoint / separate store), +// a derived-pipeline preview, and the general settings (auto-trigger tokens + +// preserve-system-prompt). // import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslations, useLocale } from "next-intl"; // Import Card/Toggle from their direct module paths rather than the @/shared/components // barrel: the barrel transitively pulls a heavy/Node-only module that hangs the @@ -60,12 +61,20 @@ interface CompressionConfig { // Best-effort pre-warm of the SLM model on enable / cold restart. Default false. ultraSlmPrewarm?: boolean; // Phase 4 (C): adaptive context-budget. Absent / mode:"off" = legacy auto-trigger. - // The panel currently surfaces the computed target read-only; mode/policy editors are a - // follow-up (the load/save path does not yet populate this field). contextBudget?: ContextBudgetConfig; liveZone?: { enabled: boolean }; } +const CONTEXT_BUDGET_MODES = new Set([ + "off", + "floor", + "replace-autotrigger", +]); +const CONTEXT_BUDGET_POLICIES = new Set([ + "reserve-output", + "percentage", + "absolute", +]); const CAVEMAN_OUTPUT_LEVELS: CavemanIntensity[] = ["lite", "full", "ultra"]; const DEFAULT_CONFIG: CompressionConfig = { @@ -78,6 +87,7 @@ const DEFAULT_CONFIG: CompressionConfig = { outputStyles: [], ultraEngine: "heuristic", ultraSlmPrewarm: false, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, liveZone: { enabled: false }, }; @@ -120,22 +130,73 @@ function LiveZoneToggle({ ); } -function AdaptiveTargetPreview({ contextBudget }: { contextBudget?: ContextBudgetConfig }) { +function AdaptiveContextBudgetDial({ + contextBudget, + saving, + onChange, +}: { + contextBudget: ContextBudgetConfig; + saving: boolean; + onChange: (patch: Partial) => void; +}) { const t = useTranslations("settings"); - const target = getAdaptiveTargetSummary(contextBudget ?? DEFAULT_CONTEXT_BUDGET, 200000); + // Representative window for the preview label (D-C1). Not the live model limit — + // the panel has no selected-model context here; 200k is Claude-class default. + const target = getAdaptiveTargetSummary(contextBudget, 200000); return ( -
- {target.enabled - ? t("compressionAdaptiveTarget", { - mode: target.mode, - policy: target.policy, - target: target.target, - contextLimit: target.contextLimit, - }) - : t("compressionAdaptiveOff")} +
+ + {(contextBudget.mode ?? "off") !== "off" && ( + + )} +
+ {target.enabled + ? t("compressionAdaptiveTarget", { + mode: target.mode, + policy: target.policy, + target: target.target, + contextLimit: target.contextLimit, + }) + : t("compressionAdaptiveOff")} +
); } @@ -153,19 +214,29 @@ export default function CompressionPanel() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [status, setStatus] = useState<"" | "saved" | "error">(""); + const configRef = useRef(config); + useEffect(() => { + configRef.current = config; + }, [config]); + const saveGenRef = useRef(0); + const lastConfirmedRef = useRef(config); + const lastAckedGenRef = useRef(0); useEffect(() => { fetch("/api/settings/compression") .then((r) => (r.ok ? r.json() : null)) .then((data: Partial | null) => { if (data) { - setConfig({ + const hydrated: CompressionConfig = { ...DEFAULT_CONFIG, ...data, engines: normalizeEngines(data.engines), cavemanOutputMode: data.cavemanOutputMode ?? DEFAULT_CONFIG.cavemanOutputMode, outputStyles: data.outputStyles ?? DEFAULT_CONFIG.outputStyles, - }); + contextBudget: { ...DEFAULT_CONTEXT_BUDGET, ...(data.contextBudget ?? {}) }, + }; + lastConfirmedRef.current = hydrated; + setConfig(hydrated); } }) .catch(() => {}) @@ -181,8 +252,24 @@ export default function CompressionPanel() { // Persist a merge-patch. The DB persists `engines` as one whole row, so callers that // touch an engine pass the full engines map to avoid dropping the other engines. + // Generation + configRef: a later in-flight save must not let an older failure + // roll back a newer optimistic (or already-acked) state. const save = async (updates: Partial) => { - const next = { ...config, ...updates }; + const gen = ++saveGenRef.current; + const previous = configRef.current; + const next: CompressionConfig = { + ...previous, + ...updates, + ...(updates.contextBudget + ? { + contextBudget: { + ...(previous.contextBudget ?? DEFAULT_CONTEXT_BUDGET), + ...updates.contextBudget, + }, + } + : {}), + }; + configRef.current = next; setConfig(next); setSaving(true); setStatus(""); @@ -192,16 +279,34 @@ export default function CompressionPanel() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(updates), }); - if (res.ok) { - setStatus("saved"); - setTimeout(() => setStatus(""), 2000); - } else { - setStatus("error"); + // Acked server state is recorded even when this gen is stale, so a + // later failure rolls back to the newest acked PUT, not the GET. + // lastAckedGenRef stops an older ack from overwriting a newer one. + if (res.ok && gen >= lastAckedGenRef.current) { + lastConfirmedRef.current = next; + lastAckedGenRef.current = gen; + } + if (gen === saveGenRef.current) { + if (res.ok) { + setStatus("saved"); + const savedGen = gen; + setTimeout(() => { + if (savedGen === saveGenRef.current) setStatus(""); + }, 2000); + } else { + configRef.current = lastConfirmedRef.current; + setConfig(lastConfirmedRef.current); + setStatus("error"); + } } } catch { - setStatus("error"); + if (gen === saveGenRef.current) { + configRef.current = lastConfirmedRef.current; + setConfig(lastConfirmedRef.current); + setStatus("error"); + } } finally { - setSaving(false); + if (gen === saveGenRef.current) setSaving(false); } }; @@ -326,8 +431,15 @@ export default function CompressionPanel() { {derivedText}
- {/* Adaptive context-budget — read-only computed target (Phase 4C, D-C1 transparency) */} - + {/* Adaptive context-budget dial — mode/policy persist via PUT contextBudget */} + { + const current = configRef.current.contextBudget ?? DEFAULT_CONTEXT_BUDGET; + save({ contextBudget: { ...current, ...patch } }); + }} + /> {/* Engine grid */}
diff --git a/src/app/(dashboard)/dashboard/conversations/page.tsx b/src/app/(dashboard)/dashboard/conversations/page.tsx index f9fb3b1848..14b8bdf4d7 100644 --- a/src/app/(dashboard)/dashboard/conversations/page.tsx +++ b/src/app/(dashboard)/dashboard/conversations/page.tsx @@ -26,6 +26,11 @@ interface ConversationRow { // 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; + // Whether the latest turn actually used previous_response_id and it + // resolved server-side — distinct from this row existing at all, which + // only means the client-side content-hash tracker saw >= 2 turns + // regardless of transport (see isGenuineContinuationTurn). + isGenuineContinuation: boolean; } // Same spinner used for an in-flight request on /dashboard/logs @@ -101,6 +106,23 @@ function StatusBadge({ status }: { status: number | null }) { ); } +// Distinguishes a conversation whose latest turn actually used +// previous_response_id (server-verified — see isGenuineContinuationTurn) +// from one the content-hash tracker merely counts as multi-turn while still +// resending full history each request. +function ContinuationBadge({ isGenuine }: { isGenuine: boolean }) { + if (!isGenuine) return null; + return ( + + bolt + continuation + + ); +} + /** * Builds the exact NormalizedBlock (src/mitm/inspector/types.ts) the * request-detail panel already builds from buildRequestTurns/ @@ -268,13 +290,15 @@ function ConversationsPageContent() { // 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); + // Tracks the PREVIOUS render's activeCallLogId truthiness, so the + // reply-just-finished effect below can detect the true->false transition + // specifically (not "is currently falsy", which would also fire on mount + // / switching conversations). + const wasReplyActiveRef = useRef(false); - // 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. + // The background list poll below only runs this while no conversation + // modal is open — see loadActiveConversationSummary and the poll effect + // for the lighter single-row path used while one is open. const loadConversations = useCallback(() => { if (document.visibilityState !== "visible") return; return fetch("/api/conversations?limit=100", { cache: "no-store" }) @@ -290,22 +314,51 @@ function ConversationsPageContent() { }); }, []); + // While the modal is open, only the one open conversation's summary needs + // to stay live (see the resync effect below) — refetching and + // re-annotating the whole up-to-100-row list every poll tick just to pluck + // that one row back out is pure waste, and at a 1s poll interval it's + // waste on every tick. Patches the row in place so the existing resync + // effect (keyed on `conversations`) picks it up unchanged. + const loadActiveConversationSummary = useCallback((id: string) => { + if (document.visibilityState !== "visible") return; + return fetch(`/api/conversations/${id}`, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + const fresh = data?.conversation; + if (!fresh) return; + setConversations((prev) => { + const idx = prev.findIndex((c) => c.id === fresh.id); + if (idx === -1) return prev; + const next = prev.slice(); + next[idx] = fresh; + return next; + }); + }) + .catch(() => {}); + }, []); + useEffect(() => { - loadConversations(); - const interval = setInterval(loadConversations, pollSeconds * 1000); + const poll = () => + activeConversationId + ? loadActiveConversationSummary(activeConversationId) + : loadConversations(); + poll(); + const interval = setInterval(poll, pollSeconds * 1000); return () => { clearInterval(interval); }; - }, [pollSeconds, loadConversations]); + }, [pollSeconds, loadConversations, loadActiveConversationSummary, activeConversationId]); // 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. + // while you're still reading it. Re-synced from `conversations` whenever + // that refreshes — the effect above keeps it fresh whether the modal is + // closed (full list poll) or open (single-conversation poll patches this + // same row in place). useEffect(() => { if (!activeConversationId) return; const fresh = conversations.find((c) => c.id === activeConversationId); @@ -461,13 +514,13 @@ function ConversationsPageContent() { // 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(); + // started streaming after that tick, row.activeCallLogId is still null + // and the live-text poll effect never starts until a fresh summary + // lands (the exact "opened it and saw nothing, closed and reopened and + // saw it live" report). setActiveConversation above already changes + // activeConversationId, which is a dependency of the poll effect below + // — it tears down and re-fires immediately on that change, forcing the + // single-row resync here for free without a second explicit call. fetchConversationPage(row.id, `limit=${CONVERSATION_PAGE_SIZE}`) .then((page) => { setConversationNodes(page?.nodes ?? []); @@ -480,7 +533,7 @@ function ConversationsPageContent() { scrollToBottom(); }); }, - [router, fetchConversationPage, scrollToBottom, loadConversations] + [router, fetchConversationPage, scrollToBottom] ); const closeConversation = useCallback(() => { @@ -583,6 +636,35 @@ function ConversationsPageContent() { return () => clearInterval(interval); }, [activeConversationId, pollSeconds, fetchConversationPage]); + // Live incident (2026-09-02): resolveConversationId reassigns a node's + // last_correlation_id to the CURRENT request at request-START (before its + // reply streams), but that request's call-log artifact -- what + // resolveTurnDisplayContent needs to show real text -- is only written at + // completion. A node touched by a still-in-flight request therefore + // legitimately resolves empty if fetched during that window; the afterSeq + // poll above only ever APPENDS strictly newer nodes, so one already + // rendered empty stays empty in local state forever, even once its + // artifact exists moments later -- the exact "empty until you close and + // reopen the conversation" symptom. Once a reply that was streaming + // finishes (activeCallLogId's true -> false transition -- see the + // wasReplyActiveRef doc comment), re-fetch the recent page and merge it in + // by id (never drop older "Load more" history) so any node that resolved + // empty during the race gets its real content without a manual reopen. + useEffect(() => { + const wasActive = wasReplyActiveRef.current; + wasReplyActiveRef.current = Boolean(activeCallLogId); + if (!wasActive || activeCallLogId || !activeConversationId) return; + + fetchConversationPage(activeConversationId, `limit=${CONVERSATION_PAGE_SIZE}`).then((page) => { + if (!page || page.nodes.length === 0) return; + setConversationNodes((prev) => { + const byId = new Map(prev.map((n) => [n.id, n] as const)); + for (const n of page.nodes) byId.set(n.id, n); + return [...byId.values()].sort((a, b) => a.seq - b.seq); + }); + }); + }, [activeCallLogId, activeConversationId, 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 @@ -655,6 +737,7 @@ function ConversationsPageContent() { lastStatus: null, isActive: false, activeCallLogId: null, + isGenuineContinuation: false, } ); }, [initialConversationParam, loading, conversations, openConversation]); @@ -759,6 +842,7 @@ function ConversationsPageContent() { > {row.id.slice(0, 16)}… + {row.turnCount} turns @@ -785,6 +869,7 @@ function ConversationsPageContent() { Conversation Turns + Continuation Last Model Provider Status @@ -816,6 +901,9 @@ function ConversationsPageContent() { {row.turnCount} + + + {row.lastModel ?? "—"} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx index 026aa324fa..dc1966677f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx @@ -15,6 +15,8 @@ interface EditCompatibleNodeModalNode { chatPath?: string; modelsPath?: string; iconUrl?: string; + dailyQuotaResetTimezone?: string | null; + dailyQuotaResetHour?: number | null; providerSpecificData?: Record; } @@ -48,6 +50,8 @@ export default function EditCompatibleNodeModal({ consoleApiKey: "", newApiUserId: "", quotaPerUnit: "", + dailyQuotaResetTimezone: "", + dailyQuotaResetHour: "", }); const [saving, setSaving] = useState(false); const [checkKey, setCheckKey] = useState(""); @@ -98,6 +102,11 @@ export default function EditCompatibleNodeModal({ consoleApiKey: typeof psd.consoleApiKey === "string" ? psd.consoleApiKey : "", newApiUserId: typeof psd.newApiUserId === "string" ? psd.newApiUserId : "", quotaPerUnit: typeof psd.quotaPerUnit === "number" ? String(psd.quotaPerUnit) : "", + dailyQuotaResetTimezone: node.dailyQuotaResetTimezone || "", + dailyQuotaResetHour: + node.dailyQuotaResetHour === 0 || node.dailyQuotaResetHour + ? String(node.dailyQuotaResetHour) + : "", }); setSaveError(null); setIconUrlError(null); @@ -141,6 +150,10 @@ export default function EditCompatibleNodeModal({ modelsPath: isCcCompatible ? "" : formData.modelsPath, iconUrl: formData.iconUrl.trim(), }; + const tz = formData.dailyQuotaResetTimezone.trim(); + payload.dailyQuotaResetTimezone = tz || null; + const hourRaw = formData.dailyQuotaResetHour.trim(); + payload.dailyQuotaResetHour = hourRaw === "" ? null : Number(hourRaw); if (!isAnthropic) { payload.apiType = formData.apiType; } @@ -345,6 +358,22 @@ export default function EditCompatibleNodeModal({ hint={t("modelsPathHint")} /> )} + + setFormData({ ...formData, dailyQuotaResetTimezone: e.target.value }) + } + placeholder="Asia/Shanghai" + hint={t("dailyQuotaResetTimezoneHint")} + /> + setFormData({ ...formData, dailyQuotaResetHour: e.target.value })} + placeholder="0" + hint={t("dailyQuotaResetHourHint")} + />
)}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts b/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts index 8c676c74be..6c7209b1aa 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/connectionsSearchFilter.ts @@ -4,7 +4,8 @@ * * Case-insensitive, plain SUBSTRING match (mirrors the semantics of * `src/shared/utils/modelCatalogSearch.ts` — do not reimplement a fuzzy - * matcher here). Matches against id, tag, name, and email. + * matcher here). Matches against id, tag, name, email, and + * providerSpecificData.baseUrl (#12108). */ import type { ConnectionRowConnection } from "./components/ConnectionRow"; @@ -17,6 +18,11 @@ function getConnectionTag(conn: ConnectionRowConnection): string { return typeof tag === "string" ? tag : ""; } +function getConnectionBaseUrl(conn: ConnectionRowConnection): string { + const baseUrl = conn.providerSpecificData?.baseUrl; + return typeof baseUrl === "string" ? baseUrl : ""; +} + /** True when `conn` matches `query` (empty/whitespace query always matches). */ export function matchesAccountQuery(query: string, conn: ConnectionRowConnection): boolean { const normalizedQuery = normalize(query); @@ -27,6 +33,7 @@ export function matchesAccountQuery(query: string, conn: ConnectionRowConnection normalize(getConnectionTag(conn)), normalize(conn.name), normalize(conn.email), + normalize(getConnectionBaseUrl(conn)), ]; return haystacks.some((haystack) => haystack.includes(normalizedQuery)); } diff --git a/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx b/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx index 0363f205d8..b9f9417b50 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ImportProvidersFromFileModal.tsx @@ -4,6 +4,12 @@ import { useTranslations } from "next-intl"; import { Button, Modal } from "@/shared/components"; import type { ParsedProviderImportEntry, ProviderImportParseError } from "./parseProviderImportFile"; import { useImportProvidersFromFile } from "./useImportProvidersFromFile"; +import { + downloadProviderImportCsvTemplate, + formatImportErrorLine, + visibleImportErrors, + type ImportResult, +} from "./providerImportFeedback"; interface ImportProvidersFromFileModalProps { isOpen: boolean; @@ -122,6 +128,30 @@ function FilePickerRow({ fileInputRef, fileName, onFile, t }: FilePickerRowProps ); } +function ImportResultPanel({ result, t }: { result: ImportResult; t: Translator }) { + const { shown, extra } = visibleImportErrors(result.errors); + const failed = result.failed > 0 || shown.length > 0; + return ( +
+ {t("importFromFileResult", { success: result.success, failed: result.failed })} + {shown.length > 0 && ( +
    + {shown.map((err, i) => ( +
  • {formatImportErrorLine(err)}
  • + ))} + {extra > 0 &&
  • {t("importFromFileMoreErrors", { count: extra })}
  • } +
+ )} +
+ ); +} + /** * Wizard step: upload a CSV/JSON file listing MULTIPLE, possibly different providers, * pick which parsed rows to actually import, then submit them in one batch (#6836). @@ -141,18 +171,18 @@ export function ImportProvidersFromFileModal({ s.handleClose(onClose)} title={t("importFromFileTitle")} maxWidth="xl">

{t("importFromFileDescription")}

+

{t("importFromFileSchemaHint")}

- {s.result && ( -
- {t("importFromFileResult", { success: s.result.success, failed: s.result.failed })} -
- )} + {s.result && }
+ diff --git a/src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts b/src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts new file mode 100644 index 0000000000..4f1564051a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts @@ -0,0 +1,165 @@ +/** + * #12071 — import-modal feedback helpers. + * + * POST /api/providers/import already returns per-row `{index,name,provider,message}`. + * The modal used to keep only success/failed/total and drop `errors` on the floor. + * These helpers stay a pure, dependency-free module so the hook can stay under the + * LOC ratchet and the same formatter can be unit-tested without React. + */ + +export type ImportRowError = { + index?: number; + name?: string; + provider?: string; + message: string; +}; + +export type ImportResult = { + success: number; + failed: number; + total: number; + errors: ImportRowError[]; +}; + +const VISIBLE_ERROR_CAP = 10; + +function asFiniteNumber(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function asRowError(value: unknown): ImportRowError | null { + if (!value || typeof value !== "object") return null; + const row = value as Record; + if (typeof row.message !== "string" || !row.message.trim()) return null; + return { + ...(typeof row.index === "number" && Number.isFinite(row.index) ? { index: row.index } : {}), + ...(typeof row.name === "string" && row.name.trim() ? { name: row.name.trim() } : {}), + ...(typeof row.provider === "string" && row.provider.trim() ? { provider: row.provider.trim() } : {}), + message: row.message.trim(), + }; +} + +/** Keep counts plus a sanitized `errors` array. A missing/non-array field becomes []. */ +export function normalizeImportResponse(data: unknown): ImportResult { + const body = data && typeof data === "object" ? (data as Record) : {}; + const rawErrors = Array.isArray(body.errors) ? body.errors : []; + return { + success: asFiniteNumber(body.success), + failed: asFiniteNumber(body.failed), + total: asFiniteNumber(body.total), + errors: rawErrors.map(asRowError).filter((row): row is ImportRowError => row !== null), + }; +} + +export type ImportHttpOutcome = { + result: ImportResult; + shouldRefresh: boolean; +}; + +function httpFailureResult(status: number, data: unknown, fallback: ImportResult): ImportResult { + if (fallback.errors.length > 0) { + return { ...fallback, success: 0 }; + } + const body = data && typeof data === "object" ? (data as Record) : {}; + const detail = typeof body.error === "string" ? body.error.trim() : ""; + const message = detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`; + return { + success: 0, + failed: Math.max(1, fallback.failed), + total: Math.max(1, fallback.total), + errors: [{ message }], + }; +} + +/** + * Map an import HTTP response onto the modal result. + * Non-ok statuses still populate `errors`. Refresh is a boolean so the hook + * can await `onImported` outside this function (a throw there must not + * overwrite a successful import result). + */ +export function applyImportHttpOutcome( + res: { ok: boolean; status: number }, + data: unknown +): ImportHttpOutcome { + const normalized = normalizeImportResponse(data); + if (!res.ok) { + return { result: httpFailureResult(res.status, data, normalized), shouldRefresh: false }; + } + return { result: normalized, shouldRefresh: normalized.success > 0 }; +} + +/** Parse the import response body. Non-JSON becomes `{ ok: false, data: { error } }`. */ +export async function readImportResponse(res: Response): Promise<{ + ok: boolean; + status: number; + data: unknown; +}> { + try { + return { ok: res.ok, status: res.status, data: await res.json() }; + } catch { + return { ok: false, status: res.status, data: { error: "Invalid JSON body" } }; + } +} + +export function networkImportFailure(err: unknown): ImportResult { + return { + success: 0, + failed: 1, + total: 1, + errors: [{ message: err instanceof Error ? err.message : "Import request failed" }], + }; +} + +/** First 10 rows plus the leftover count — same cap as AddApiKeyModal bulk import. */ +export function visibleImportErrors(errors: ImportRowError[]): { + shown: ImportRowError[]; + extra: number; +} { + return { + shown: errors.slice(0, VISIBLE_ERROR_CAP), + extra: Math.max(0, errors.length - VISIBLE_ERROR_CAP), + }; +} + +/** One line for the modal list: name, else provider, else 1-based row index. */ +export function formatImportErrorLine(err: ImportRowError): string { + const label = + (typeof err.name === "string" && err.name.trim()) || + (typeof err.provider === "string" && err.provider.trim()) || + (typeof err.index === "number" && Number.isFinite(err.index) ? `row ${err.index + 1}` : "row"); + return `${label}: ${err.message}`; +} + +/** + * Positional CSV sample. Column 0 must be an *existing* managed provider id + * or an already-registered OpenAI/Anthropic-compatible node id — this import + * does not create new endpoint nodes. Header names are cosmetic; the parser + * destructures by index (`provider,name,apiKey,baseUrl,priority`). + */ +export const PROVIDER_IMPORT_CSV_TEMPLATE = `# OmniRoute provider import (positional columns) +# Columns: provider, name, apiKey, baseUrl (optional), priority (optional, 1-100) +# The provider column must be an existing managed provider id (openai, anthropic, …) +# or an already-registered OpenAI/Anthropic-compatible node id. +# This import does not create new endpoint nodes. Add those first (Dashboard → Providers → Add OpenAI-Compatible). +provider,name,apiKey,baseUrl,priority +openai,Prod OpenAI,sk-your-openai-key,,1 +`; + +export function downloadTextFile(content: string, filename: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + try { + document.body.appendChild(link); + link.click(); + } finally { + link.remove(); + URL.revokeObjectURL(url); + } +} + +export function downloadProviderImportCsvTemplate(): void { + downloadTextFile(PROVIDER_IMPORT_CSV_TEMPLATE, "omniroute-provider-import-template.csv", "text/csv"); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts b/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts index ecf50e5bcb..a09f00e0c4 100644 --- a/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts +++ b/src/app/(dashboard)/dashboard/providers/components/useImportProvidersFromFile.ts @@ -4,13 +4,17 @@ import { type ParsedProviderImportEntry, type ProviderImportParseError, } from "./parseProviderImportFile"; +import { + applyImportHttpOutcome, + networkImportFailure, + readImportResponse, + type ImportResult, +} from "./providerImportFeedback"; -export type ImportResult = { success: number; failed: number; total: number }; +export type { ImportResult }; /** - * All state + handlers for `ImportProvidersFromFileModal`, split into a hook purely - * to keep the component's own function under the repo's max-lines-per-function ratchet - * (#6836). Behavior is unchanged — this is a pure extraction, not a refactor. + * State + handlers for ImportProvidersFromFileModal (#6836/#12071). */ export function useImportProvidersFromFile(onImported: () => Promise) { const fileInputRef = useRef(null); @@ -66,15 +70,19 @@ export function useImportProvidersFromFile(onImported: () => Promise) { setImporting(true); try { const res = await fetch("/api/providers/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entries: toImport }), }); - const data = await res.json().catch(() => ({})); - if (res.ok) { - setResult({ success: data.success ?? 0, failed: data.failed ?? 0, total: data.total ?? 0 }); - await onImported(); + const parsed = await readImportResponse(res); + const outcome = applyImportHttpOutcome(parsed, parsed.data); + setResult(outcome.result); + if (outcome.shouldRefresh) { + try { + await onImported(); + } catch { /* refresh failure must not replace the import result */ } } + } catch (err) { + setResult(networkImportFailure(err)); } finally { setImporting(false); } diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index a65478dd2a..a9cde7c6f7 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -558,7 +558,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const rawNoAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats); @@ -576,7 +577,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const apiKeyProviderEntriesAll = buildStaticProviderEntries("apikey", getProviderStats); @@ -595,7 +597,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const aggregatorProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => AGGREGATOR_PROVIDER_IDS.has(entry.providerId) @@ -607,7 +610,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const imageProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => IMAGE_ONLY_PROVIDER_IDS.has(entry.providerId) @@ -619,7 +623,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const enterpriseProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => ENTERPRISE_CLOUD_PROVIDER_IDS.has(entry.providerId) @@ -631,7 +636,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const videoProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => VIDEO_PROVIDER_IDS.has(entry.providerId) @@ -643,7 +649,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const embeddingRerankProviderEntriesAll = apiKeyProviderEntriesAll.filter((entry) => EMBEDDING_RERANK_PROVIDER_IDS.has(entry.providerId) @@ -655,7 +662,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const webCookieProviderEntriesAll = buildStaticProviderEntries("web-cookie", getProviderStats); @@ -666,7 +674,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const localProviderEntriesAll = buildStaticProviderEntries("local", getProviderStats); @@ -677,7 +686,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const searchProviderEntriesAll = buildStaticProviderEntries("search", getProviderStats); @@ -688,7 +698,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const audioProviderEntriesAll = buildStaticProviderEntries("audio", getProviderStats); @@ -699,7 +710,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const cloudAgentProviderEntriesAll = buildStaticProviderEntries("cloud-agent", getProviderStats); @@ -710,7 +722,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const upstreamProxyEntriesAll = buildStaticProviderEntries("upstream-proxy", getProviderStats); @@ -721,7 +734,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const compatibleProviderEntriesAll = [ @@ -754,7 +768,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const staticProviderEntriesAll = dedupeProviderEntries([ @@ -780,7 +795,8 @@ function ProvidersPageContent() { undefined, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); // IDE providers: subset of oauth/apikey providers that are editors/IDEs with @@ -796,7 +812,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const oauthOnlyEntriesAll = oauthProviderEntriesAll @@ -817,7 +834,8 @@ function ProvidersPageContent() { showFreeOnly, modelSearchQuery, activeServiceKind, - liveModelsByProviderId + liveModelsByProviderId, + connections ); const compactProviderEntries = buildCompactProviderEntriesForPage({ diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index 8c7be96c71..8356a1f9e5 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -421,6 +421,27 @@ function getFilterableModelsForEntry( return [...staticModels, ...liveModels]; } +/** + * Dashboard-card search identity for an imported connection (#12108). + * Only `name` and `providerSpecificData.baseUrl` — those are the two + * fields the issue asked for. id/tag/email stay on the detail-page + * haystack (`matchesAccountQuery`); surfacing a provider card from an + * account email would mix account-picker UX into the catalog filter. + */ +export type ProviderSearchConnection = { + provider?: string | null; + name?: string | null; + providerSpecificData?: Record | null; +}; + +function connectionSearchHaystacks(conn: ProviderSearchConnection): string[] { + const baseUrl = conn.providerSpecificData?.baseUrl; + return [ + typeof conn.name === "string" ? conn.name : "", + typeof baseUrl === "string" ? baseUrl : "", + ]; +} + export function filterConfiguredProviderEntries( entries: ProviderEntry[], showConfiguredOnly: boolean, @@ -428,7 +449,8 @@ export function filterConfiguredProviderEntries( showFreeOnly?: boolean, modelSearchQuery?: string, serviceKindFilter?: string | null, - liveModelsByProviderId?: LiveModelsByProviderId + liveModelsByProviderId?: LiveModelsByProviderId, + connections?: ProviderSearchConnection[] ): ProviderEntry[] { let filtered = entries; @@ -461,9 +483,26 @@ export function filterConfiguredProviderEntries( if (searchQuery && searchQuery.trim()) { filtered = filtered.filter((entry) => { const provider = entry.provider as Record; - return ( + if ( matchesAnyToken(String(provider.name || ""), searchQuery) || matchesAnyToken(entry.providerId, searchQuery) + ) { + return true; + } + // #12108: imported connections live under the canonical provider card. + // Match their operator-visible name / baseUrl so "Grade-S-Node" or an + // IP in the search box surfaces the OpenAI card instead of vanishing. + // Same matcher as provider.name / providerId above (matchesAnyToken: + // full-string first, then whitespace-token OR). The detail page uses + // a single-substring haystack — that is a different surface, not a + // bug in this filter. + if (!connections || connections.length === 0) return false; + return connections.some( + (conn) => + connectionBelongsToProviderPage(conn.provider, entry.providerId) && + connectionSearchHaystacks(conn).some((haystack) => + matchesAnyToken(haystack, searchQuery) + ) ); }); } diff --git a/src/app/(dashboard)/dashboard/settings/advanced/page.tsx b/src/app/(dashboard)/dashboard/settings/advanced/page.tsx index 5dfefd170b..af8eb23deb 100644 --- a/src/app/(dashboard)/dashboard/settings/advanced/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/advanced/page.tsx @@ -5,6 +5,7 @@ import LogToolSourcesCard from "../components/LogToolSourcesCard"; import PayloadRulesTab from "../components/PayloadRulesTab"; import RequestLimitsTab from "../components/RequestLimitsTab"; import CliproxyapiSettingsTab from "../components/CliproxyapiSettingsTab"; +import HeadroomProxyCard from "../components/HeadroomProxyCard"; export default function SettingsAdvancedPage() { return ( @@ -14,6 +15,7 @@ export default function SettingsAdvancedPage() { +
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/HeadroomProxyCard.tsx b/src/app/(dashboard)/dashboard/settings/components/HeadroomProxyCard.tsx new file mode 100644 index 0000000000..de37387cec --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/HeadroomProxyCard.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card, Button, Input } from "@/shared/components"; +import { isHttpUrl } from "@/shared/validation/schemas/misc"; + +const HEADROOM_URL_MAX = 500; + +function isValidHeadroomUrl(value: string): boolean { + const trimmed = value.trim(); + if (trimmed === "") return true; + return trimmed.length <= HEADROOM_URL_MAX && isHttpUrl(trimmed); +} + +type SettingsErrorBody = { + error?: { + message?: string; + details?: { field?: string; message?: string }[]; + }; +}; + +function settingsErrorText(body: SettingsErrorBody, fallback: string): string { + const first = body.error?.details?.[0]; + if (first?.message) { + return first.field ? `${first.field}: ${first.message}` : first.message; + } + return body.error?.message || fallback; +} + +interface HeadroomStatus { + url?: string; + running?: boolean; + canStart?: boolean; + localUrl?: boolean; + installed?: boolean; +} + +export default function HeadroomProxyCard() { + const t = useTranslations("settings"); + const [url, setUrl] = useState(""); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [acting, setActing] = useState(false); + const [status, setStatus] = useState(null); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + const saveAc = useRef(null); + const lifecycleAc = useRef(null); + + const refreshStatus = useCallback(async (signal?: AbortSignal) => { + const res = await fetch("/api/headroom/status", signal ? { signal } : undefined); + if (!res.ok) return; + const data = (await res.json()) as HeadroomStatus; + if (signal?.aborted) return; + setStatus(data); + }, []); + + useEffect(() => { + const ac = new AbortController(); + // Async continuation so every setState happens after an await + // (react-hooks/set-state-in-effect: no synchronous setState in effect bodies). + void (async () => { + try { + const r = await fetch("/api/settings", { signal: ac.signal }); + const data = (r.ok ? await r.json() : {}) as Record; + if (ac.signal.aborted) return; + if (typeof data.headroomUrl === "string") setUrl(data.headroomUrl); + } catch { + // ignore + } finally { + if (!ac.signal.aborted) setLoaded(true); + } + // Status is for start/stop buttons only. Do not copy status.url into the + // input -- that value is HEADROOM_URL fallback and would overwrite empty. + try { + await refreshStatus(ac.signal); + } catch { + // ignore + } + })(); + return () => { + ac.abort(); + saveAc.current?.abort(); + lifecycleAc.current?.abort(); + }; + }, [refreshStatus]); + + const save = useCallback(async () => { + if (!isValidHeadroomUrl(url)) { + setMsg({ ok: false, text: t("cliproxyapiInvalidUrl") }); + return; + } + saveAc.current?.abort(); + const ac = new AbortController(); + saveAc.current = ac; + const { signal } = ac; + setSaving(true); + setMsg(null); + const trimmed = url.trim(); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ headroomUrl: trimmed }), + signal, + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as SettingsErrorBody; + throw new Error(settingsErrorText(body, `HTTP ${res.status}`)); + } + if (signal.aborted) return; + setUrl(trimmed); + setMsg({ ok: true, text: t("settingSaved") }); + } catch (error) { + if (signal.aborted) return; + setMsg({ + ok: false, + text: error instanceof Error ? error.message : t("settingSaveFailed"), + }); + return; + } finally { + if (saveAc.current === ac) setSaving(false); + } + try { + await refreshStatus(signal); + } catch { + // PATCH already succeeded; status is best-effort. + } + }, [url, t, refreshStatus]); + + const postLifecycle = useCallback( + async (path: "/api/headroom/start" | "/api/headroom/stop") => { + lifecycleAc.current?.abort(); + const ac = new AbortController(); + lifecycleAc.current = ac; + const { signal } = ac; + setActing(true); + setMsg(null); + try { + const res = await fetch(path, { method: "POST", signal }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as SettingsErrorBody; + throw new Error(settingsErrorText(body, `HTTP ${res.status}`)); + } + try { + await refreshStatus(signal); + } catch { + // start/stop already succeeded; status is best-effort. + } + } catch (error) { + if (signal.aborted) return; + setMsg({ + ok: false, + text: error instanceof Error ? error.message : t("settingSaveFailed"), + }); + } finally { + if (lifecycleAc.current === ac) setActing(false); + } + }, + [refreshStatus, t] + ); + + if (!loaded) return null; + + const canStart = status?.canStart === true; + const running = status?.running === true; + const busy = saving || acting; + + return ( + +
+
+ compress +
+
+

{t("headroomProxyTitle")}

+

{t("headroomProxyDesc")}

+
+
+ + {msg && ( +
+ + {msg.ok ? "check_circle" : "error"} + + {msg.text} +
+ )} + +
+
+ + setUrl(e.target.value)} + placeholder="http://localhost:8787" + className="w-full" + disabled={busy} + /> +

{t("headroomProxyUrlHint")}

+
+
+ + + +
+ {status && !canStart && !status.localUrl && ( +

{t("headroomProxyExternalHint")}

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx index 5c844f80cb..f23b36ff5d 100644 --- a/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx @@ -11,6 +11,7 @@ import { normalizeCliCompatProviderId, } from "@/shared/constants/cliCompatProviders"; import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { CLAUDE_CODE_CLIENT_BUILD_REVISION } from "@/shared/constants/claudeCodeClient"; import { compareTr } from "@/shared/utils/turkishText"; import { HERMES } from "./systemTransformsHermesDefaults"; @@ -169,7 +170,7 @@ const DEFAULT_SYSTEM_TRANSFORMS_CLIENT = { entrypoint: "sdk-cli", versionFormat: "ex-machina", cchAlgo: "sha256-first-user", - buildRevision: "1f2", + buildRevision: CLAUDE_CODE_CLIENT_BUILD_REVISION, }, ], }, diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index d5f7fa0de0..eda5308eb4 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -16,8 +16,8 @@ import { } from "./utils"; import Card from "@/shared/components/Card"; import { CardSkeleton } from "@/shared/components/Loading"; -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; import { pickDisplayValue } from "@/shared/utils/maskEmail"; +import { supportsProviderQuota, isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import { useNotificationStore } from "@/store/notificationStore"; @@ -32,7 +32,6 @@ import { formatAutoRefreshCountdown } from "./formatters"; import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback"; import { compareTr } from "@/shared/utils/turkishText"; import { fetchWithTimeout } from "@/shared/utils/fetchTimeout"; -import { isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility"; // Bound the two first-paint requests so a stalled connection cannot wedge // `initialLoading` on `true` and freeze the quota page on its skeleton forever @@ -529,7 +528,7 @@ export default function ProviderLimits({ connections.filter( (conn) => isProviderQuotaVisible(conn) && - USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && + supportsProviderQuota(conn.provider, conn) && (conn.authType === "oauth" || conn.authType === "apikey") ), [connections] diff --git a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx index 37b11ff286..3e91bd3526 100644 --- a/src/app/(dashboard)/home/ProviderQuotaWidget.tsx +++ b/src/app/(dashboard)/home/ProviderQuotaWidget.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import Card from "@/shared/components/Card"; import ProviderIcon from "@/shared/components/ProviderIcon"; -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { supportsProviderQuota } from "@/shared/utils/providerQuotaVisibility"; import QuotaMiniBar from "../dashboard/usage/components/ProviderLimits/QuotaMiniBar"; import { PROVIDER_LABEL } from "../dashboard/usage/components/ProviderLimits/constants"; import { translateUsageOrFallback } from "../dashboard/usage/components/ProviderLimits/i18nFallback"; @@ -25,6 +25,7 @@ type Connection = { name?: string; displayName?: string; email?: string; + providerSpecificData?: unknown; }; type QuotaData = Record; @@ -178,7 +179,7 @@ export default function ProviderQuotaWidget({ const quotaResponseData = quotasResponse.ok ? await quotasResponse.json() : {}; const relevant = ((connectionData.connections || []) as Connection[]).filter( (connection) => - USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) && + supportsProviderQuota(connection.provider, connection) && (connection.authType === "oauth" || connection.authType === "apikey") ); setConnections(relevant); diff --git a/src/app/api/conversations/[id]/route.ts b/src/app/api/conversations/[id]/route.ts new file mode 100644 index 0000000000..999e2147df --- /dev/null +++ b/src/app/api/conversations/[id]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { getMultiTurnConversationById } from "@/lib/db/agenticConversations"; +import { annotateConversationRow, buildActiveCallLogIdByConversation } from "../route"; + +export const dynamic = "force-dynamic"; + +/** + * Single-conversation summary — used by the dashboard's conversation modal + * to keep lastModel/lastStatus/isActive/activeCallLogId fresh on the auto- + * refresh interval while it's open, instead of the list route re-fetching + * and re-annotating up to 200 rows just to pluck one back out. The turns + * themselves live-update through the separate .../tree poll; this only + * covers the summary fields the modal header and "Goto latest request" + * read off the row. + */ +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(req); + if (authError) return authError; + + try { + const { id } = await params; + if (!id) return NextResponse.json({ error: "Missing id" }, { status: 400 }); + + const row = getMultiTurnConversationById(id); + if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const activeCallLogIdByConversation = buildActiveCallLogIdByConversation(); + const conversation = annotateConversationRow(row, activeCallLogIdByConversation); + + return NextResponse.json({ conversation }); + } catch (err) { + console.error("[API ERROR] /api/conversations/[id] failed:", err); + return NextResponse.json({ error: "Failed to fetch conversation" }, { status: 500 }); + } +} diff --git a/src/app/api/conversations/route.ts b/src/app/api/conversations/route.ts index 9b5ebc3746..e603460dfb 100644 --- a/src/app/api/conversations/route.ts +++ b/src/app/api/conversations/route.ts @@ -1,10 +1,53 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import type { MultiTurnConversationRow } from "@/lib/db/agenticConversations"; import { listMultiTurnConversations } from "@/lib/db/agenticConversations"; import { getPendingById } from "@/lib/usage/usageHistory"; +import { isGenuineContinuationTurn } from "@/lib/db/responsesContinuationStore"; export const dynamic = "force-dynamic"; +/** + * Shared row -> API-shape annotation for both the list route and the + * single-conversation route below: isActive/activeCallLogId (pending-request + * cross reference) and isGenuineContinuation (artifact-backed, cached — see + * isGenuineContinuationTurn) need the exact same computation regardless of + * whether the caller asked for one row or many. Strips the internal-only + * lastArtifactRelPath/lastApiKeyId fields before they reach the client. + */ +export function annotateConversationRow( + row: MultiTurnConversationRow, + activeCallLogIdByConversation: ReadonlyMap +) { + const { lastArtifactRelPath, lastApiKeyId, ...rest } = row; + return { + ...rest, + isActive: activeCallLogIdByConversation.has(row.id), + activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null, + isGenuineContinuation: isGenuineContinuationTurn(lastArtifactRelPath, lastApiKeyId), + }; +} + +/** + * A pending (still-streaming) request's sessionTag is the conversation's own + * id (agentic_conversations.id === call_logs.session_tag) — cross reference + * so a conversation row can show "in progress" without a separate poll. + * `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's + * INSERT needs duration/status/tokens, none of which exist yet), so + * lastCallLogId always lags one request behind while a reply is still + * streaming — it can't be used to fetch the in-flight response. Surfacing + * the pending request's own id separately lets the conversation panel poll + * /api/logs/[id] for it directly (same live-partial-text path + * RequestLoggerDetail already uses). + */ +export function buildActiveCallLogIdByConversation(): Map { + const map = new Map(); + for (const pending of getPendingById().values()) { + if (pending.sessionTag) map.set(pending.sessionTag, pending.id); + } + return map; +} + export async function GET(req: Request) { const authError = await requireManagementAuth(req); if (authError) return authError; @@ -19,25 +62,10 @@ export async function GET(req: Request) { offset: Number.isFinite(offset) ? offset : undefined, }); - // A pending (still-streaming) request's sessionTag is the conversation's - // own id (agentic_conversations.id === call_logs.session_tag) — cross - // reference so the list can show "in progress" without a separate poll. - // `call_logs` only gets its row on completion (src/lib/usage/callLogs.ts's - // INSERT needs duration/status/tokens, none of which exist yet), so - // `lastCallLogId` from listMultiTurnConversations always lags one request - // behind while a reply is still streaming — it can't be used to fetch the - // in-flight response. Surface the pending request's own id separately so - // the conversation panel can poll /api/logs/[id] for it directly (same - // live-partial-text path RequestLoggerDetail already uses). - const activeCallLogIdByConversation = new Map(); - for (const pending of getPendingById().values()) { - if (pending.sessionTag) activeCallLogIdByConversation.set(pending.sessionTag, pending.id); - } - const conversations = rows.map((row) => ({ - ...row, - isActive: activeCallLogIdByConversation.has(row.id), - activeCallLogId: activeCallLogIdByConversation.get(row.id) ?? null, - })); + const activeCallLogIdByConversation = buildActiveCallLogIdByConversation(); + const conversations = rows.map((row) => + annotateConversationRow(row, activeCallLogIdByConversation) + ); return NextResponse.json({ conversations, total }); } catch (err) { diff --git a/src/app/api/logs/[id]/route.ts b/src/app/api/logs/[id]/route.ts index 7c20cfb819..afdb7d2432 100644 --- a/src/app/api/logs/[id]/route.ts +++ b/src/app/api/logs/[id]/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { getCallLogById } from "@/lib/usageDb"; import { getCompletedDetails, getPendingById } from "@/lib/usage/usageHistory"; +import { + extractPreviousResponseId, + resolveCallLogIdByResponseId, +} from "@/lib/db/responsesContinuationStore"; // Each logged chunk-array element is one raw network read, timestamp-prefixed // for the debug display — NOT one complete SSE `data:` line. A single JSON @@ -159,7 +163,20 @@ export async function GET( if (!persistedRequest) return NextResponse.json({ error: "Not found" }, { status: 404 }); - return NextResponse.json(persistedRequest); + // "Continues from" link for the dashboard's conversation panel: resolve + // this entry's own previous_response_id back to the call-log row that + // produced it. Persisted-only (apiKeyId isn't plumbed onto the + // pending/in-memory branches above) -- required for the same tenant + // scoping resolveCallLogIdByResponseId enforces, so an active/in-memory + // entry simply renders no parent link rather than resolving unscoped. + const previousResponseId = extractPreviousResponseId( + persistedRequest.pipelinePayloads as Record | null | undefined + ); + const parentLogId = previousResponseId + ? resolveCallLogIdByResponseId(previousResponseId, persistedRequest.apiKeyId ?? null) + : null; + + return NextResponse.json({ ...persistedRequest, previousResponseId, parentLogId }); } catch (err) { console.error("[API ERROR] /api/logs/[id] failed:", err); return NextResponse.json({ error: "Failed to fetch log" }, { status: 500 }); diff --git a/src/app/api/provider-nodes/[id]/route.ts b/src/app/api/provider-nodes/[id]/route.ts index eef4803372..19bd524ef8 100644 --- a/src/app/api/provider-nodes/[id]/route.ts +++ b/src/app/api/provider-nodes/[id]/route.ts @@ -56,7 +56,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: if (isValidationFailure(validation)) { return NextResponse.json({ error: validation.error }, { status: 400 }); } - const { name, prefix, apiType, baseUrl, chatPath, modelsPath, customHeaders, iconUrl } = + const { name, prefix, apiType, baseUrl, chatPath, modelsPath, customHeaders, iconUrl, dailyQuotaResetTimezone, dailyQuotaResetHour } = validation.data; const node: any = await getProviderNodeById(id); @@ -98,6 +98,9 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: // previously stored custom icon. iconUrl: iconUrl?.trim() || null, customHeaders: customHeaders || null, + dailyQuotaResetTimezone: dailyQuotaResetTimezone?.trim() || null, + dailyQuotaResetHour: + dailyQuotaResetHour === 0 || dailyQuotaResetHour != null ? dailyQuotaResetHour : null, }; if (node.type === "openai-compatible") { @@ -106,6 +109,21 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: const updated = await updateProviderNode(id, updates); + try { + const { registerMoonshotFetchersForNodes } = await import( + "@omniroute/open-sse/services/moonshotQuotaFetcher.ts" + ); + registerMoonshotFetchersForNodes([ + { + id: typeof updated?.id === "string" ? updated.id : id, + prefix: prefix.trim(), + baseUrl: sanitizedBaseUrl, + }, + ]); + } catch (error) { + console.warn("Moonshot fetcher re-register after node update skipped:", error); + } + const connections = await getProviderConnections({ provider: id }); await Promise.all( connections.flatMap((connectionRaw) => { diff --git a/src/app/api/provider-nodes/route.ts b/src/app/api/provider-nodes/route.ts index a11180f505..d60e3b299a 100644 --- a/src/app/api/provider-nodes/route.ts +++ b/src/app/api/provider-nodes/route.ts @@ -48,6 +48,27 @@ function sanitizeVibeProxyBaseUrl(baseUrl: string) { return `${base}/v1`; } +async function registerMoonshotFetchersForCreatedNode(node: { + id?: unknown; + prefix?: unknown; + baseUrl?: unknown; +}): Promise { + try { + const { registerMoonshotFetchersForNodes } = await import( + "@omniroute/open-sse/services/moonshotQuotaFetcher.ts" + ); + registerMoonshotFetchersForNodes([ + { + id: typeof node.id === "string" ? node.id : null, + prefix: typeof node.prefix === "string" ? node.prefix : null, + baseUrl: typeof node.baseUrl === "string" ? node.baseUrl : null, + }, + ]); + } catch (error) { + console.warn("Moonshot fetcher register after node create skipped:", error); + } +} + function sanitizeAnthropicBaseUrl(baseUrl: string) { return (baseUrl || "") .trim() @@ -126,6 +147,8 @@ export async function POST(request) { modelsPath, customHeaders, iconUrl, + dailyQuotaResetTimezone, + dailyQuotaResetHour, } = validation.data; if (preset === "vibeproxy-openai") { @@ -145,7 +168,11 @@ export async function POST(request) { modelsPath: modelsPath || null, iconUrl: iconUrl?.trim() || null, customHeaders: customHeaders || null, + dailyQuotaResetTimezone: dailyQuotaResetTimezone?.trim() || null, + dailyQuotaResetHour: + dailyQuotaResetHour === 0 || dailyQuotaResetHour != null ? dailyQuotaResetHour : null, }); + await registerMoonshotFetchersForCreatedNode(node); return NextResponse.json({ node }, { status: 201 }); } @@ -170,7 +197,11 @@ export async function POST(request) { modelsPath: modelsPath || null, iconUrl: iconUrl?.trim() || null, customHeaders: customHeaders || null, + dailyQuotaResetTimezone: dailyQuotaResetTimezone?.trim() || null, + dailyQuotaResetHour: + dailyQuotaResetHour === 0 || dailyQuotaResetHour != null ? dailyQuotaResetHour : null, }); + await registerMoonshotFetchersForCreatedNode(node); return NextResponse.json({ node }, { status: 201 }); } @@ -200,7 +231,11 @@ export async function POST(request) { modelsPath: compatMode === "cc" ? null : modelsPath || null, iconUrl: iconUrl?.trim() || null, customHeaders: customHeaders || null, + dailyQuotaResetTimezone: dailyQuotaResetTimezone?.trim() || null, + dailyQuotaResetHour: + dailyQuotaResetHour === 0 || dailyQuotaResetHour != null ? dailyQuotaResetHour : null, }); + await registerMoonshotFetchersForCreatedNode(node); return NextResponse.json({ node }, { status: 201 }); } diff --git a/src/app/api/providers/route.ts b/src/app/api/providers/route.ts index bb5d1ded72..409f7445ab 100644 --- a/src/app/api/providers/route.ts +++ b/src/app/api/providers/route.ts @@ -47,7 +47,15 @@ import { fetchModelSyncInternal, getModelSyncInternalBaseUrl, } from "@/shared/services/modelSyncScheduler"; -import { finalizeValidatedChatGptWebCodexSecrets } from "@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"; +// Dynamically imported below, inside the one `provider === "chatgpt-web-codex"` +// branch that needs it: this module's transitive chain pulls in tiktoken's +// WASM tokenizer, which Turbopack dev mode fails to resolve for this graph +// even with `tiktoken` listed in serverExternalPackages (the standalone +// Node require works fine; only Turbopack's bundling of this import path +// doesn't). A static top-level import evaluates that whole chain on EVERY +// /api/providers request regardless of provider, turning an unrelated- +// provider bug into a route-wide 500. Loading it lazily, only when actually +// needed, avoids paying that cost (and that risk) on the common path. import { isAutoFetchModelsEnabled } from "@/lib/providerModels/modelDiscovery"; import { testSingleConnection } from "./[id]/test/route"; import { rejectRetiredCommonChatGptWebProvider } from "@/lib/providers/chatgptWebRetirementResponse"; @@ -204,6 +212,8 @@ export async function POST(request: Request) { ? providerSpecificData.validationId : ""; try { + const { finalizeValidatedChatGptWebCodexSecrets } = + await import("@omniroute/open-sse/services/chatgptWebCodexAdmin.ts"); const finalized = finalizeValidatedChatGptWebCodexSecrets(apiKey || "", validationId); persistedApiKey = finalized.encodedCredential; providerSpecificData = { ...(providerSpecificData || {}) }; @@ -324,11 +334,16 @@ export async function POST(request: Request) { }) .then((syncRes) => { if (!syncRes.ok) { - console.log(`[providers] Auto-sync failed for ${newConnection.id}: ${syncRes.status}`); + console.log( + `[providers] Auto-sync failed for ${newConnection.id}: ${syncRes.status}` + ); } }) .catch((err) => { - console.log(`[providers] Auto-sync error for ${newConnection.id}:`, err?.message || err); + console.log( + `[providers] Auto-sync error for ${newConnection.id}:`, + err?.message || err + ); }); } catch (syncSetupError) { // Defensive: if URL parsing or header construction itself throws, do diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index 377512ee13..0065eb61ea 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -38,7 +38,7 @@ import { resolveCodexAccount, type CodexPersistedQuotaState, } from "@omniroute/open-sse/services/codexAccount/index.ts"; -import { getAntigravityQuotaFamily } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { selectAntigravityQuotaWindowNames } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; // ─── Types ────────────────────────────────────────────────────────────────── @@ -273,37 +273,7 @@ function resolveAntigravityQuotaWindowsForModel( quotaNames: string[], requestedModel: string ): string[] { - const requestedFamily = getAntigravityQuotaFamily(requestedModel); - const cleanRequestedModel = requestedModel.replace(/^(antigravity|agy)\//, ""); - const bareModel = cleanRequestedModel.includes("/") - ? cleanRequestedModel.slice(cleanRequestedModel.lastIndexOf("/") + 1) - : cleanRequestedModel; - - if (requestedFamily === "other") { - return quotaNames.filter((windowName) => { - const bare = windowName.replace(/^(antigravity|agy)\//, ""); - return bare === bareModel || bare === cleanRequestedModel; - }); - } - - const familyAggregates = - requestedFamily === "gemini" - ? ["gemini_weekly"] - : requestedFamily === "claude" - ? ["claude_gpt_weekly"] - : []; - - const exactWindows = quotaNames.filter((windowName) => { - const bare = windowName.replace(/^(antigravity|agy)\//, ""); - return bare === bareModel; - }); - const aggregateWindows = familyAggregates.filter((key) => quotaNames.includes(key)); - const scoped = [...exactWindows, ...aggregateWindows]; - if (scoped.length > 0) return scoped; - - return quotaNames.filter( - (windowName) => getAntigravityQuotaFamily(windowName) === requestedFamily - ); + return selectAntigravityQuotaWindowNames(quotaNames, requestedModel); } function isAntigravityQuotaExhausted( diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index bbccf84868..029da5e073 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "عنوان URL للأيقونة", "iconUrlHint": "اختياري. عنوان URL للصورة المعروضة كأيقونة لهذا المزود.", "iconUrlInvalid": "رابط الأيقونة غير صالح. استخدم http(s):// أو data:image/*;base64 رابط.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "أنشئ مفتاح API على https://app.requesty.ai، ثم الصقه هنا كرمز Bearer. نقطة نهاية متوافقة مع OpenAI على https://router.requesty.ai/v1، مع كتالوج /v1/models مباشر.", "runwayml": "يعتمد توليد الفيديو في Runway على المهام. يرسل OmniRoute وظائف تحويل النص إلى فيديو أو الصورة إلى فيديو، ويستعلم من /v1/tasks/[id]، ويقوم بتطبيع مخرجات الفيديو النهائية مرة أخرى إلى استجابة /v1/videos/generations الشبيهة بـ OpenAI.", "sambanova": "رصيد مجاني بقيمة 5$ عند التسجيل (صلاحية 30 يومًا)، لا يتطلب بطاقة ائتمان", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "يستخدم اكتشاف النماذج /v2/lm/scenarios/foundation-models/models على AI_API_URL. تستخدم طلبات الدردشة deploymentUrl/chat/completions وتتطلب AI-Resource-Group.", "sarvam": "سارفام AI متوافق مع OpenAI على /v1. يقوم OmniRoute بفحص /v1/models ويوجه حركة الدردشة إلى /v1/chat/completions. تم ضبط النماذج للغات الهندية.", "scaleway": "1 مليون رمز مميز مجاني للحسابات الجديدة — متوافق مع الاتحاد الأوروبي/GDPR (باريس)، Qwen3 235B وLlama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "الوضع: {mode}", "compressionAdaptiveOff": "ميزانية السياق التكيفية: معطلة (المشغل التلقائي القديم)", "compressionAdaptiveTarget": "تكيفي ({mode}، السياسة: {policy}) — الهدف ≈ {target, number} رمز (لنافذة من {contextLimit, number} رمز)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "حقن تعليمات تشكيل الاستجابة دون إعادة كتابة مخرجات المزود. ادمج بحرية.", "mcpAccessibilityDescription": "يحدد نطاق مخرجات أداة MCP (مخزن منفصل).", "compressionStylesTileSummary": "{tokens, number} رمز تم توفيره · {runs, plural, one {# تشغيل تم تنسيقه} other {# تشغيلات تم تنسيقها}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "الصحة", "cliproxyapiPort": "منفذ", "qdrantHost": "مضيف", - "qdrantCollection": "مجموعة" + "qdrantCollection": "مجموعة", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "محرك آر تي كيه", diff --git a/src/i18n/messages/az.json b/src/i18n/messages/az.json index 495c04ad7e..54989e93e8 100644 --- a/src/i18n/messages/az.json +++ b/src/i18n/messages/az.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "İkon URL-i", "iconUrlHint": "İstəyə bağlı. Bu provayderin ikonu kimi göstərilən şəkil URL-i.", "iconUrlInvalid": "Yanlış ikon URL-si. http(s):// və ya data:image/*;base64 URL istifadə edin.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai ünvanında API açarı yaradın, sonra onu bura Bearer tokeni kimi yapışdırın. OpenAI ilə uyğun son nöqtə canlı /v1/models kataloqu ilə https://router.requesty.ai/v1 ünvanındadır.", "runwayml": "Runway video yaradılması tapşırıq əsaslıdır. OmniRoute mətndən-videoya və ya şəkildən-videoya tapşırıqlarını təqdim edir, /v1/tasks/[id] ünvanını sorğulayır və tamamlanmış video çıxışlarını yenidən OpenAI tipli /v1/videos/generations cavabına normallaşdırır.", "sambanova": "Qeydiyyatdan keçdikdə $5 pulsuz kredit (30 gün etibarlılıq müddəti), kredit kartı tələb olunmur", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Model kəşfi AI_API_URL üzərində /v2/lm/scenarios/foundation-models/models istifadə edir. Söhbət sorğuları deploymentUrl/chat/completions istifadə edir və AI-Resource-Group tələb edir.", "sarvam": "Sarvam AI OpenAI ilə uyğun gəlir /v1. OmniRoute /v1/models-i yoxlayır və söhbət trafikini /v1/chat/completions-a yönləndirir. Modellər Hind dilləri üçün tənzimlənmişdir.", "scaleway": "Yeni hesablar üçün 1M pulsuz token — Aİ/GDPR uyğun (Paris), Qwen3 235B və Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "rejim: {mode}", "compressionAdaptiveOff": "Adaptiv kontekst büdcəsi: qapalı (köhnə avtomatik tətikləyici)", "compressionAdaptiveTarget": "Adaptiv ({mode}, siyasət: {policy}) — hədəf ≈ {target, number} token ({contextLimit, number}-tokenlik pəncərə üçün)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Provayder çıxışını yenidən yazmadan cavab-formalaşdıran təlimatları daxil edin. Sərbəst şəkildə birləşdirin.", "mcpAccessibilityDescription": "MCP alət çıxışlarını əhatə edir (ayrıca depo).", "compressionStylesTileSummary": "{tokens, number} tokenə qənaət edilib · {runs, plural, one {# işəsalma üslublaşdırılıb} other {# işəsalma üslublaşdırılıb}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Sağlamlıq", "cliproxyapiPort": "Port", "qdrantHost": "Ev sahibi", - "qdrantCollection": "Kolleksiya" + "qdrantCollection": "Kolleksiya", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index cf36ba76ac..a4038688d1 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL адрес на икона", "iconUrlHint": "По избор. URL адрес на изображение, показвано като икона на този доставчик.", "iconUrlInvalid": "Невалиден URL на иконата. Използвайте http(s):// или data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Създайте API ключ на https://app.requesty.ai, след което го поставете тук като Bearer токен. Съвместима с OpenAI крайна точка на https://router.requesty.ai/v1 с каталог на живо за /v1/models.", "runwayml": "Генерирането на видео в Runway е базирано на задачи. OmniRoute изпраща задачи за text-to-video или image-to-video, проверява периодично /v1/tasks/[id] и нормализира готовите видео резултати обратно в наподобяващ OpenAI отговор на /v1/videos/generations.", "sambanova": "$5 безплатни кредити при регистрация (валидност 30 дни), не се изисква кредитна карта", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Откриването на модели използва /v2/lm/scenarios/foundation-models/models на AI_API_URL. Заявките за чат използват deploymentUrl/chat/completions и изискват AI-Resource-Group.", "sarvam": "Sarvam AI е съвместим с OpenAI на /v1. OmniRoute проучва /v1/models и маршрутизира чат трафика към /v1/chat/completions. Моделите са настроени за индийски езици.", "scaleway": "1M безплатни токена за нови акаунти — съвместимо с EU/GDPR (Париж), Qwen3 235B и Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "режим: {mode}", "compressionAdaptiveOff": "Адаптивен бюджет за контекст: изключен (наследено автоматично задействане)", "compressionAdaptiveTarget": "Адаптивен ({mode}, политика: {policy}) — цел ≈ {target, number} токена (за прозорец от {contextLimit, number} токена)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Вмъкване на инструкции за оформяне на отговора без пренаписване на изхода от доставчика. Комбинирайте свободно.", "mcpAccessibilityDescription": "Ограничава обхвата на изходите от MCP инструменти (отделно хранилище).", "compressionStylesTileSummary": "{tokens, number} спестени токена · {runs, plural, one {# стилизирано изпълнение} other {# стилизирани изпълнения}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Здраве", "cliproxyapiPort": "Порт", "qdrantHost": "Хост", - "qdrantCollection": "Колекция" + "qdrantCollection": "Колекция", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index e95d363cb7..13ff32f312 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "আইকন URL", "iconUrlHint": "ঐচ্ছিক। এই প্রদানকারীর আইকন হিসেবে দেখানোর জন্য ছবির URL।", "iconUrlInvalid": "অবৈধ আইকন URL। একটি http(s):// অথবা data:image/*;base64 URL ব্যবহার করুন।", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai-এ একটি API কী তৈরি করুন, তারপর এটি এখানে Bearer টোকেন হিসেবে পেস্ট করুন। https://router.requesty.ai/v1-এ OpenAI-সামঞ্জস্যপূর্ণ এন্ডপয়েন্ট, সাথে একটি লাইভ /v1/models ক্যাটালগ রয়েছে।", "runwayml": "Runway ভিডিও জেনারেশন টাস্ক-ভিত্তিক। OmniRoute টেক্সট-টু-ভিডিও বা ইমেজ-টু-ভিডিও জব সাবমিট করে, /v1/tasks/[id] পোল করে এবং সমাপ্ত ভিডিও আউটপুটগুলোকে আবার OpenAI-এর মতো /v1/videos/generations রেসপন্সে নরমালাইজ করে।", "sambanova": "সাইন আপ করার সময় $5 ফ্রি ক্রেডিট (৩০ দিনের মেয়াদ), কোনো ক্রেডিট কার্ডের প্রয়োজন নেই", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "মডেল ডিসকভারি AI_API_URL-এ /v2/lm/scenarios/foundation-models/models ব্যবহার করে। চ্যাট রিকোয়েস্টগুলো deploymentUrl/chat/completions ব্যবহার করে এবং এর জন্য AI-Resource-Group প্রয়োজন।", "sarvam": "Sarvam AI OpenAI-সঙ্গত /v1-এ। OmniRoute /v1/models-এ প্রোব করে এবং চ্যাট ট্রাফিককে /v1/chat/completions-এ রাউট করে। মডেলগুলি ইন্ডিক ভাষার জন্য টিউন করা হয়েছে।", "scaleway": "নতুন অ্যাকাউন্টের জন্য 1M ফ্রি টোকেন — EU/GDPR কমপ্লায়েন্ট (প্যারিস), Qwen3 235B এবং Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "মোড: {mode}", "compressionAdaptiveOff": "অ্যাডাপ্টিভ কনটেক্সট বাজেট: বন্ধ (লেগাসি অটো-ট্রিগার)", "compressionAdaptiveTarget": "অ্যাডাপ্টিভ ({mode}, পলিসি: {policy}) — টার্গেট ≈ {target, number} টোকেন ({contextLimit, number}-টোকেন উইন্ডোর জন্য)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "প্রোভাইডার আউটপুট রিরাইট না করেই রেসপন্স-শেপিং নির্দেশাবলী ইনজেক্ট করুন। অবাধে একত্রিত করুন।", "mcpAccessibilityDescription": "MCP টুল আউটপুট স্কোপ করে (আলাদা স্টোর)।", "compressionStylesTileSummary": "{tokens, number} টোকেন সাশ্রয় হয়েছে · {runs, plural, one {#টি রান স্টাইল করা হয়েছে} other {#টি রান স্টাইল করা হয়েছে}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "স্বাস্থ্য", "cliproxyapiPort": "পোর্ট", "qdrantHost": "হোস্ট", - "qdrantCollection": "সংগ্রহ" + "qdrantCollection": "সংগ্রহ", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 1fd9fd2b65..653db54fcb 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ikony", "iconUrlHint": "Volitelné. URL obrázku zobrazeného jako ikona tohoto poskytovatele.", "iconUrlInvalid": "Neplatná URL ikony. Použijte http(s):// nebo data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Vytvořte API klíč na adrese https://app.requesty.ai a poté jej vložte sem jako Bearer token. Koncový bod kompatibilní s OpenAI je na adrese https://router.requesty.ai/v1, s živým katalogem /v1/models.", "runwayml": "Generování videa v Runway je založeno na úlohách. OmniRoute odesílá úlohy typu text-na-video nebo obrázek-na-video, dotazuje se na /v1/tasks/[id] a normalizuje hotové video výstupy zpět do odpovědi typu /v1/videos/generations podobné OpenAI.", "sambanova": "Bezplatný kredit 5 $ při registraci (platnost 30 dní), není vyžadována platební karta", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Vyhledávání modelů používá /v2/lm/scenarios/foundation-models/models na AI_API_URL. Požadavky na chat používají deploymentUrl/chat/completions a vyžadují AI-Resource-Group.", "sarvam": "Sarvam AI je kompatibilní s OpenAI na /v1. OmniRoute prozkoumává /v1/models a směruje chatový provoz na /v1/chat/completions. Modely jsou laděny pro indické jazyky.", "scaleway": "1 milion bezplatných tokenů pro nové účty — v souladu s EU/GDPR (Paříž), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "režim: {mode}", "compressionAdaptiveOff": "Adaptivní rozpočet kontextu: vypnuto (starší automatické spouštění)", "compressionAdaptiveTarget": "Adaptivní ({mode}, zásada: {policy}) — cíl ≈ {target, number} tokenů (pro okno o velikosti {contextLimit, number} tokenů)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Vkládejte instrukce pro formování odpovědi bez přepisování výstupu poskytovatele. Libovolně kombinujte.", "mcpAccessibilityDescription": "Omezuje rozsah výstupů nástrojů MCP (samostatné úložiště).", "compressionStylesTileSummary": "{tokens, number} ušetřených tokenů · {runs, plural, one {# stylované spuštění} few {# stylovaná spuštění} other {# stylovaných spuštění}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Zdraví", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Kolekce" + "qdrantCollection": "Kolekce", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 44310acf04..acb6841b15 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Ikon-URL", "iconUrlHint": "Valgfrit. Billed-URL, der vises som denne udbyders ikon.", "iconUrlInvalid": "Ugyldig ikon-URL. Brug en http(s):// eller data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Opret en API-nøgle på https://app.requesty.ai, og indsæt den derefter her som en Bearer-token. OpenAI-kompatibelt slutpunkt på https://router.requesty.ai/v1 med et live /v1/models-katalog.", "runwayml": "Runway-videogenerering er opgavebaseret. OmniRoute indsender tekst-til-video- eller billede-til-video-job, poller /v1/tasks/[id] og normaliserer de færdige videooutput tilbage til det OpenAI-lignende /v1/videos/generations-svar.", "sambanova": "$5 i gratis kredit ved tilmelding (30 dages gyldighed), intet kreditkort påkrævet", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Modelfindelse bruger /v2/lm/scenarios/foundation-models/models på AI_API_URL. Chatanmodninger bruger deploymentUrl/chat/completions og kræver AI-Resource-Group.", "sarvam": "Sarvam AI er OpenAI-kompatibel på /v1. OmniRoute undersøger /v1/models og dirigerer chattrafik til /v1/chat/completions. Modellerne er tilpasset til indiske sprog.", "scaleway": "1M gratis tokens til nye konti — EU/GDPR-kompatibel (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "tilstand: {mode}", "compressionAdaptiveOff": "Adaptivt kontekstbudget: fra (forældet auto-trigger)", "compressionAdaptiveTarget": "Adaptiv ({mode}, politik: {policy}) — mål ≈ {target, number} tokens (for et {contextLimit, number}-token vindue)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Indsæt instruktioner til formning af svar uden at omskrive udbyderens output. Kombiner frit.", "mcpAccessibilityDescription": "Afgrænser MCP-værktøjsoutput (separat lager).", "compressionStylesTileSummary": "{tokens, number} tokens sparet · {runs, plural, one {# kørsel stylet} other {# kørsler stylet}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Sundhed", "cliproxyapiPort": "Port", "qdrantHost": "Vært", - "qdrantCollection": "Samling" + "qdrantCollection": "Samling", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 377d2392aa..81fe972442 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Icon-URL", "iconUrlHint": "Optional. Bild-URL, die als Icon dieses Anbieters angezeigt wird.", "iconUrlInvalid": "Ungültige Icon-URL. Verwenden Sie eine http(s):// oder data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "AC-Prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Erstellen Sie einen API-Schlüssel unter https://app.requesty.ai und fügen Sie ihn hier als Bearer-Token ein. OpenAI-kompatibler Endpunkt unter https://router.requesty.ai/v1 mit einem Live-Katalog unter /v1/models.", "runwayml": "Die Runway-Videogenerierung ist aufgabenbasiert. OmniRoute übermittelt Text-to-Video- oder Image-to-Video-Jobs, fragt /v1/tasks/[id] ab und normalisiert die fertigen Videoausgaben zurück in die OpenAI-ähnliche Antwort von /v1/videos/generations.", "sambanova": "5 $ kostenloses Guthaben bei Registrierung (30 Tage Gültigkeit), keine Kreditkarte erforderlich", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Die Modellerkennung verwendet /v2/lm/scenarios/foundation-models/models auf AI_API_URL. Chat-Anfragen verwenden deploymentUrl/chat/completions und erfordern AI-Resource-Group.", "sarvam": "Sarvam AI ist OpenAI-kompatibel unter /v1. OmniRoute durchsucht /v1/models und leitet den Chat-Verkehr an /v1/chat/completions weiter. Die Modelle sind auf indische Sprachen abgestimmt.", "scaleway": "1 Mio. kostenlose Token für neue Konten — EU-DSGVO-konform (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "Modus: {mode}", "compressionAdaptiveOff": "Adaptives Kontextbudget: aus (Legacy-Auto-Trigger)", "compressionAdaptiveTarget": "Adaptiv ({mode}, Richtlinie: {policy}) — Ziel ≈ {target, number} Tokens (für ein {contextLimit, number}-Token-Fenster)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Anweisungen zur Antwortgestaltung einfügen, ohne die Provider-Ausgabe umzuschreiben. Frei kombinierbar.", "mcpAccessibilityDescription": "Schränkt MCP-Tool-Ausgaben ein (separater Speicher).", "compressionStylesTileSummary": "{tokens, number} Tokens eingespart · {runs, plural, one {# Ausführung gestylt} other {# Ausführungen gestylt}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Gesundheit", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Sammlung" + "qdrantCollection": "Sammlung", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ec1e427c3a..4dbc28341a 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5067,6 +5067,9 @@ "importFromFileImporting": "Importing…", "importFromFileImport": "Import {count} providers", "importFromFileResult": "Imported {success} providers ({failed} failed)", + "importFromFileDownloadTemplate": "Download CSV template", + "importFromFileMoreErrors": "+{count} more", + "importFromFileSchemaHint": "CSV columns are positional: provider, name, apiKey, baseUrl (optional), priority (optional). The provider column must be an existing managed provider id or an already-registered OpenAI/Anthropic-compatible node id — this import does not create new endpoint nodes.", "adaptaTutorial": { "title": "How to connect Adapta Web", "introPrefix": "Adapta authenticates through Clerk. The token", @@ -5248,6 +5251,10 @@ "iconUrlLabel": "Icon URL", "iconUrlHint": "Optional. Image URL shown as this provider's icon.", "iconUrlInvalid": "Invalid icon URL. Use an http(s):// or data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6246,7 @@ "requesty": "Create an API key at https://app.requesty.ai, then paste it here as a Bearer token. OpenAI-compatible endpoint at https://router.requesty.ai/v1, with a live /v1/models catalog.", "runwayml": "Runway video generation is task-based. OmniRoute submits text-to-video or image-to-video jobs, polls /v1/tasks/[id], and normalizes the finished video outputs back into the OpenAI-like /v1/videos/generations response.", "sambanova": "$5 free credits on signup (30-day validity), no credit card required", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Model discovery uses /v2/lm/scenarios/foundation-models/models on AI_API_URL. Chat requests use deploymentUrl/chat/completions and require AI-Resource-Group.", "sarvam": "Sarvam AI is OpenAI-compatible on /v1. OmniRoute probes /v1/models and routes chat traffic to /v1/chat/completions. Models are tuned for Indic languages.", "scaleway": "1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7820,14 @@ "compressionDerivedMode": "mode: {mode}", "compressionAdaptiveOff": "Adaptive context budget: off (legacy auto-trigger)", "compressionAdaptiveTarget": "Adaptive ({mode}, policy: {policy}) — target ≈ {target, number} tokens (for a {contextLimit, number}-token window)", + "compressionAdaptiveMode": "Adaptive context budget", + "compressionAdaptiveModeOff": "Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "Replace auto-trigger", + "compressionAdaptivePolicy": "Budget policy", + "compressionAdaptivePolicyReserve": "Reserve output", + "compressionAdaptivePolicyPercentage": "Percentage of window", + "compressionAdaptivePolicyAbsolute": "Absolute token budget", "compressionOutputStylesDescription": "Inject response-shaping instructions without rewriting provider output. Combine freely.", "mcpAccessibilityDescription": "Scopes MCP tool outputs (separate store).", "compressionStylesTileSummary": "{tokens, number} tokens saved · {runs, plural, one {# run styled} other {# runs styled}}", @@ -7907,6 +7923,14 @@ "cliproxyapiFallback": "CLIProxyAPI Fallback", "cliproxyapiEnableFallback": "Enable CLIProxyAPI Fallback", "cliproxyapiUrl": "CLIProxyAPI URL", + "headroomProxyTitle": "Headroom proxy", + "headroomProxyDesc": "URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "Headroom URL", + "headroomProxyUrlHint": "Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "Save", + "headroomProxyStart": "Start", + "headroomProxyStop": "Stop", + "headroomProxyExternalHint": "This URL is not loopback, so OmniRoute will not spawn the local CLI.", "cliproxyapiStatus": "CLIProxyAPI Status", "cliproxyapiNotDetected": "Not detected", "cliproxyapiImportAuthTitle": "Import accounts from CLIProxyAPI", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index bced42b255..1e94756597 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Icon URL", "iconUrlHint": "Optional. Image URL shown as this provider's icon.", "iconUrlInvalid": "URL de icono no válida. Utilice una URL http(s):// o data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "producto ac", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Create an API key at https://app.requesty.ai, then paste it here as a Bearer token. OpenAI-compatible endpoint at https://router.requesty.ai/v1, with a live /v1/models catalog.", "runwayml": "Runway video generation is task-based. OmniRoute submits text-to-video or image-to-video jobs, polls /v1/tasks/[id], and normalizes the finished video outputs back into the OpenAI-like /v1/videos/generations response.", "sambanova": "$5 free credits on signup (30-day validity), no credit card required", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Model discovery uses /v2/lm/scenarios/foundation-models/models on AI_API_URL. Chat requests use deploymentUrl/chat/completions and require AI-Resource-Group.", "sarvam": "Sarvam AI is OpenAI-compatible on /v1. OmniRoute probes /v1/models and routes chat traffic to /v1/chat/completions. Models are tuned for Indic languages.", "scaleway": "1M free tokens for new accounts — EU/GDPR compliant (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "mode: {mode}", "compressionAdaptiveOff": "Adaptive context budget: off (legacy auto-trigger)", "compressionAdaptiveTarget": "Adaptive ({mode}, policy: {policy}) — target ≈ {target, number} tokens (for a {contextLimit, number}-token window)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Inject response-shaping instructions without rewriting provider output. Combine freely.", "mcpAccessibilityDescription": "Scopes MCP tool outputs (separate store).", "compressionStylesTileSummary": "{tokens, number} tokens saved · {runs, plural, one {# run styled} other {# runs styled}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Salud", "cliproxyapiPort": "Puerto", "qdrantHost": "Anfitrión", - "qdrantCollection": "Colección" + "qdrantCollection": "Colección", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index ca6b8eeeaf..8e52072e92 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL آیکون", "iconUrlHint": "اختیاری. URL تصویری که به عنوان آیکون این ارائه‌دهنده نمایش داده می‌شود.", "iconUrlInvalid": "آدرس آیکون نامعتبر است. از آدرس http(s):// یا data:image/*;base64 استفاده کنید.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "یک کلید API در https://app.requesty.ai بسازید، سپس آن را در اینجا به عنوان توکن Bearer جای‌گذاری کنید. نقطه پایانی سازگار با OpenAI در https://router.requesty.ai/v1، همراه با کاتالوگ زنده /v1/models.", "runwayml": "تولید ویدیو در Runway مبتنی بر وظیفه (task-based) است. OmniRoute کارهای تبدیل متن به ویدیو یا تصویر به ویدیو را ارسال می‌کند، وضعیت /v1/tasks/[id] را بررسی می‌کند و خروجی‌های ویدیوی نهایی را به پاسخ شبیه به OpenAI در /v1/videos/generations تبدیل می‌کند.", "sambanova": "۵ دلار اعتبار رایگان هنگام ثبت‌نام (با اعتبار ۳۰ روزه)، بدون نیاز به کارت اعتباری", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "کشف مدل از /v2/lm/scenarios/foundation-models/models در AI_API_URL استفاده می‌کند. درخواست‌های چت از deploymentUrl/chat/completions استفاده می‌کنند و به AI-Resource-Group نیاز دارند.", "sarvam": "Sarvam AI با OpenAI سازگار است در /v1. OmniRoute به /v1/models دسترسی پیدا می‌کند و ترافیک چت را به /v1/chat/completions هدایت می‌کند. مدل‌ها برای زبان‌های هندی تنظیم شده‌اند.", "scaleway": "۱ میلیون توکن رایگان برای حساب‌های جدید — سازگار با قوانین اتحادیه اروپا/GDPR (پاریس)، Qwen3 235B و Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "حالت: {mode}", "compressionAdaptiveOff": "بودجه محتوای تطبیقی: خاموش (محرک خودکار قدیمی)", "compressionAdaptiveTarget": "تطبیقی ({mode}، خط‌مشی: {policy}) — هدف ≈ {target, number} توکن (برای یک پنجره {contextLimit, number} توکنی)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "تزریق دستورالعمل‌های شکل‌دهی به پاسخ بدون بازنویسی خروجی ارائه‌دهنده. ترکیب آزادانه.", "mcpAccessibilityDescription": "محدوده خروجی‌های ابزار MCP (ذخیره‌ساز مجزا).", "compressionStylesTileSummary": "{tokens, number} توکن ذخیره شد · {runs, plural, one {# اجرا سبک‌دهی شد} other {# اجرا سبک‌دهی شدند}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "سلامت", "cliproxyapiPort": "پورت", "qdrantHost": "میزبان", - "qdrantCollection": "مجموعه" + "qdrantCollection": "مجموعه", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index a569a0b42f..b0f2c0711b 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Kuvakkeen URL-osoite", "iconUrlHint": "Valinnainen. Kuvan URL-osoite, joka näytetään tämän tarjoajan kuvakkeena.", "iconUrlInvalid": "Virheellinen kuvakkeen URL. Käytä http(s):// tai data:image/*;base64 URL:ia.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Luo API-avain osoitteessa https://app.requesty.ai ja liitä se sitten tähän Bearer-tokenina. OpenAI-yhteensopiva päätepiste osoitteessa https://router.requesty.ai/v1 reaaliaikaisella /v1/models-luettelolla.", "runwayml": "Runway-videonluonti on tehtäväpohjaista. OmniRoute lähettää teksti-videoksi- tai kuva-videoksi -töitä, kyselyttää polkua /v1/tasks/[id] ja normalisoi valmiit videotulosteet takaisin OpenAI-tyyliseen /v1/videos/generations-vastaukseen.", "sambanova": "$5 ilmaista saldoa rekisteröitymisen yhteydessä (voimassa 30 päivää), luottokorttia ei vaadita", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Mallien haku käyttää polkua /v2/lm/scenarios/foundation-models/models osoitteessa AI_API_URL. Chat-pyynnöt käyttävät polkua deploymentUrl/chat/completions ja vaativat AI-Resource-Group-otsakkeen.", "sarvam": "Sarvam AI on OpenAI-yhteensopiva /v1:ssä. OmniRoute tutkii /v1/malleja ja ohjaa keskusteluliikennettä /v1/chat/completions:iin. Mallit on säädetty indialaisille kielille.", "scaleway": "1M ilmaista tokenia uusille tileille — EU/GDPR-yhteensopiva (Pariisi), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "tila: {mode}", "compressionAdaptiveOff": "Mukautuva kontekstibudjetti: pois päältä (vanha automaattikäynnistys)", "compressionAdaptiveTarget": "Mukautuva ({mode}, käytäntö: {policy}) — tavoite ≈ {target, number} tokenia ({contextLimit, number} tokenin ikkunalle)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Lisää vastauksen muotoiluohjeita kirjoittamatta palveluntarjoajan tulostetta uudelleen. Yhdistele vapaasti.", "mcpAccessibilityDescription": "Rajaa MCP-työkalujen tulosteet (erillinen tallennustila).", "compressionStylesTileSummary": "{tokens, number} tokenia säästetty · {runs, plural, one {# ajo tyylitelty} other {# ajoa tyylitelty}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Terveys", "cliproxyapiPort": "Portti", "qdrantHost": "Isäntä", - "qdrantCollection": "Kokoelma" + "qdrantCollection": "Kokoelma", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 36adf6ce0c..c58a27a544 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL de l'icône", "iconUrlHint": "Facultatif. URL de l'image affichée comme icône de ce fournisseur.", "iconUrlInvalid": "URL d'icône invalide. Utilisez une URL http(s):// ou data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Créez une clé API sur https://app.requesty.ai, puis collez-la ici en tant que jeton Bearer. Point de terminaison compatible OpenAI sur https://router.requesty.ai/v1, avec un catalogue /v1/models en direct.", "runwayml": "La génération de vidéos Runway est basée sur des tâches. OmniRoute soumet des tâches text-to-video ou image-to-video, interroge /v1/tasks/[id] et normalise les sorties vidéo terminées dans la réponse de type OpenAI /v1/videos/generations.", "sambanova": "5 $ de crédits gratuits à l'inscription (validité de 30 jours), aucune carte de crédit requise", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "La découverte de modèles utilise /v2/lm/scenarios/foundation-models/models sur AI_API_URL. Les requêtes de chat utilisent deploymentUrl/chat/completions et nécessitent AI-Resource-Group.", "sarvam": "Sarvam AI est compatible avec OpenAI sur /v1. OmniRoute interroge /v1/models et achemine le trafic de chat vers /v1/chat/completions. Les modèles sont optimisés pour les langues indiennes.", "scaleway": "1M de tokens gratuits pour les nouveaux comptes — conforme UE/RGPD (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "mode : {mode}", "compressionAdaptiveOff": "Budget de contexte adaptatif : désactivé (déclenchement automatique hérité)", "compressionAdaptiveTarget": "Adaptatif ({mode}, politique : {policy}) — cible ≈ {target, number} jetons (pour une fenêtre de {contextLimit, number} jetons)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injecter des instructions de mise en forme des réponses sans réécrire la sortie du fournisseur. À combiner librement.", "mcpAccessibilityDescription": "Limite la portée des sorties d'outils MCP (magasin distinct).", "compressionStylesTileSummary": "{tokens, number} jetons économisés · {runs, plural, one {# exécution stylisée} other {# exécutions stylisées}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Santé", "cliproxyapiPort": "Port", "qdrantHost": "Hôte", - "qdrantCollection": "Collection" + "qdrantCollection": "Collection", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 1e9e9c000e..c55b42e4f1 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "આઇકન URL", "iconUrlHint": "વૈકલ્પિક. આ પ્રદાતાના આઇકન તરીકે દર્શાવેલ છબી URL.", "iconUrlInvalid": "અમાન્ય આઇકન URL. http(s):// અથવા data:image/*;base64 URL નો ઉપયોગ કરો.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai પર API key બનાવો, પછી તેને અહીં Bearer token તરીકે પેસ્ટ કરો. લાઈવ /v1/models કેટલોગ સાથે https://router.requesty.ai/v1 પર OpenAI-સુસંગત એન્ડપોઇન્ટ.", "runwayml": "Runway વીડિયો જનરેશન ટાસ્ક-આધારિત છે. OmniRoute ટેક્સ્ટ-ટુ-વીડિયો અથવા ઇમેજ-ટુ-વીડિયો જોબ્સ સબમિટ કરે છે, /v1/tasks/[id] ને પોલ કરે છે, અને પૂર્ણ થયેલા વીડિયો આઉટપુટને ફરીથી OpenAI જેવા /v1/videos/generations રિસ્પોન્સમાં નોર્મલાઇઝ કરે છે.", "sambanova": "સાઇનઅપ પર $5 મફત ક્રેડિટ્સ (30-દિવસની માન્યતા), કોઈ ક્રેડિટ કાર્ડની જરૂર નથી", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "મોડલ ડિસ્કવરી AI_API_URL પર /v2/lm/scenarios/foundation-models/models નો ઉપયોગ કરે છે. ચેટ વિનંતીઓ deploymentUrl/chat/completions નો ઉપયોગ કરે છે અને તેના માટે AI-Resource-Group જરૂરી છે.", "sarvam": "Sarvam AI OpenAI-સંગત છે /v1. OmniRoute /v1/models ને તપાસે છે અને ચેટ ટ્રાફિકને /v1/chat/completions પર રુટ કરે છે. મોડલ્સ ઇન્ડિક ભાષાઓ માટે ટ્યુન કરવામાં આવ્યા છે.", "scaleway": "નવા એકાઉન્ટ્સ માટે 1M મફત ટોકન્સ — EU/GDPR સુસંગત (પેરિસ), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "મોડ: {mode}", "compressionAdaptiveOff": "અડેપ્ટિવ કન્ટેક્સ્ટ બજેટ: બંધ (લેગસી ઑટો-ટ્રિગર)", "compressionAdaptiveTarget": "અડેપ્ટિવ ({mode}, પૉલિસી: {policy}) — લક્ષ્ય ≈ {target, number} ટોકન્સ ({contextLimit, number}-ટોકન વિન્ડો માટે)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "પ્રોવાઇડર આઉટપુટને ફરીથી લખ્યા વિના રિસ્પોન્સ-શેપિંગ સૂચનાઓ ઇન્જેક્ટ કરો. મુક્તપણે જોડો.", "mcpAccessibilityDescription": "MCP ટૂલ આઉટપુટ્સને સ્કોપ કરે છે (અલગ સ્ટોર).", "compressionStylesTileSummary": "{tokens, number} ટોકન્સ સાચવ્યા · {runs, plural, one {# રન સ્ટાઇલ કરેલ} other {# રન સ્ટાઇલ કરેલ}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "આરોગ્ય", "cliproxyapiPort": "પોર્ટ", "qdrantHost": "હોસ્ટ", - "qdrantCollection": "સંગ્રહ" + "qdrantCollection": "સંગ્રહ", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index b00b08bbce..b8f982de2b 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "כתובת URL של סמל", "iconUrlHint": "אופציונלי. כתובת URL של תמונה שתוצג כסמל של ספק זה.", "iconUrlInvalid": "כתובת ה-URL של האייקון אינה חוקית. השתמש ב-http(s):// או ב-data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "צור מפתח API בכתובת https://app.requesty.ai, ולאחר מכן הדבק אותו כאן כ-Bearer token. נקודת קצה תואמת OpenAI בכתובת https://router.requesty.ai/v1, עם קטלוג /v1/models חי.", "runwayml": "יצירת וידאו ב-Runway מבוססת משימות. OmniRoute שולח משימות text-to-video או image-to-video, דוגם את /v1/tasks/[id], ומנרמל את פלטי הווידאו המוגמרים בחזרה לתגובה דמוית OpenAI של /v1/videos/generations.", "sambanova": "קרדיט חינם בסך $5 בהרשמה (תוקף ל-30 יום), ללא צורך בכרטיס אשראי", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "גילוי מודלים משתמש ב-/v2/lm/scenarios/foundation-models/models ב-AI_API_URL. בקשות צ'אט משתמשות ב-deploymentUrl/chat/completions ודורשות את AI-Resource-Group.", "sarvam": "Sarvam AI תואם ל-OpenAI ב-/v1. OmniRoute סורק את /v1/models ומנתב את תנועת השיחה ל-/v1/chat/completions. המודלים מותאמים לשפות אינדיות.", "scaleway": "1M טוקנים בחינם לחשבונות חדשים — תואם EU/GDPR (פריז), Qwen3 235B ו-Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "מצב: {mode}", "compressionAdaptiveOff": "תקציב הקשר אדפטיבי: כבוי (טריגר אוטומטי מיושן)", "compressionAdaptiveTarget": "אדפטיבי ({mode}, מדיניות: {policy}) — יעד ≈ {target, number} טוקנים (עבור חלון של {contextLimit, number} טוקנים)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "הזרקת הנחיות לעיצוב תגובה מבלי לשכתב את פלט הספק. ניתן לשלב באופן חופשי.", "mcpAccessibilityDescription": "מגביל את הטווח של פלטי כלי MCP (אחסון נפרד).", "compressionStylesTileSummary": "{tokens, number} טוקנים נחסכו · {runs, plural, one {הרצה אחת עוצבה} other {# הרצות עוצבו}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "בריאות", "cliproxyapiPort": "פורט", "qdrantHost": "מארח", - "qdrantCollection": "אוסף" + "qdrantCollection": "אוסף", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index d733e7283f..d7b5bc6f97 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "आइकन URL", "iconUrlHint": "वैकल्पिक। इस प्रदाता के आइकन के रूप में दिखाया जाने वाला छवि URL।", "iconUrlInvalid": "अमान्य आइकन URL। http(s):// या data:image/*;base64 URL का उपयोग करें।", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "एसी-उत्पाद", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai पर एक API कुंजी बनाएं, फिर इसे यहाँ Bearer टोकन के रूप में पेस्ट करें। https://router.requesty.ai/v1 पर OpenAI-संगत एंडपॉइंट, एक लाइव /v1/models कैटलॉग के साथ।", "runwayml": "Runway वीडियो जनरेशन टास्क-आधारित है। OmniRoute टेक्स्ट-टू-वीडियो या इमेज-टू-वीडियो जॉब सबमिट करता है, /v1/tasks/[id] को पोल करता है, और तैयार वीडियो आउटपुट को वापस OpenAI जैसे /v1/videos/generations रिस्पॉन्स में सामान्य (normalize) करता है।", "sambanova": "साइनअप पर $5 मुफ्त क्रेडिट (30 दिनों की वैधता), किसी क्रेडिट कार्ड की आवश्यकता नहीं है", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "मॉडल खोज AI_API_URL पर /v2/lm/scenarios/foundation-models/models का उपयोग करती है। चैट अनुरोध deploymentUrl/chat/completions का उपयोग करते हैं और इसके लिए AI-Resource-Group की आवश्यकता होती है।", "sarvam": "Sarvam AI OpenAI के साथ संगत है /v1. OmniRoute /v1/models को प्रॉब करता है और चैट ट्रैफिक को /v1/chat/completions पर रूट करता है। मॉडल्स को इंडिक भाषाओं के लिए ट्यून किया गया है।", "scaleway": "नए खातों के लिए 1M मुफ्त टोकन — EU/GDPR अनुपालन (पेरिस), Qwen3 235B और Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "मोड: {mode}", "compressionAdaptiveOff": "अनुकूली संदर्भ बजट: बंद (लीगेसी ऑटो-ट्रिगर)", "compressionAdaptiveTarget": "अनुकूली ({mode}, नीति: {policy}) — लक्ष्य ≈ {target, number} टोकन ({contextLimit, number}-टोकन विंडो के लिए)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "प्रदाता आउटपुट को फिर से लिखे बिना प्रतिक्रिया-आकार देने वाले निर्देश इंजेक्ट करें। स्वतंत्र रूप से संयोजित करें।", "mcpAccessibilityDescription": "MCP टूल आउटपुट को स्कोप करता है (अलग स्टोर)।", "compressionStylesTileSummary": "{tokens, number} टोकन बचाए गए · {runs, plural, one {# रन स्टाइल किया गया} other {# रन स्टाइल किए गए}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "स्वास्थ्य", "cliproxyapiPort": "पोर्ट", "qdrantHost": "होस्ट", - "qdrantCollection": "संग्रह" + "qdrantCollection": "संग्रह", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index b7a866605b..50e5b97aef 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Ikon URL-címe", "iconUrlHint": "Opcionális. A szolgáltató ikonjaként megjelenő kép URL-címe.", "iconUrlInvalid": "Érvénytelen ikon URL. Használjon http(s):// vagy data:image/*;base64 URL-t.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Hozzon létre egy API-kulcsot a https://app.requesty.ai oldalon, majd illessze be ide Bearer tokenként. OpenAI-kompatibilis végpont a https://router.requesty.ai/v1 címen, élő /v1/models katalógussal.", "runwayml": "A Runway videógenerálás feladatalapú. Az OmniRoute elküldi a text-to-video vagy image-to-video feladatokat, lekérdezi a /v1/tasks/[id] állapotát, és a kész videókimeneteket visszaalakítja az OpenAI-szerű /v1/videos/generations válasszá.", "sambanova": "$5 ingyenes kredit regisztrációkor (30 napos érvényesség), bankkártya nem szükséges", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "A modellfelderítés a /v2/lm/scenarios/foundation-models/models végpontot használja az AI_API_URL címen. A chat kérések a deploymentUrl/chat/completions végpontot használják, és AI-Resource-Group szükséges hozzájuk.", "sarvam": "A Sarvam AI OpenAI-kompatibilis a /v1-en. Az OmniRoute a /v1/models-t vizsgálja és a chat forgalmat a /v1/chat/completions-re irányítja. A modellek az indiai nyelvekre vannak optimalizálva.", "scaleway": "1M ingyenes token új fiókoknak — EU/GDPR-megfelelő (Párizs), Qwen3 235B és Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "mód: {mode}", "compressionAdaptiveOff": "Adaptív kontextuskeret: kikapcsolva (örökölt automatikus indítás)", "compressionAdaptiveTarget": "Adaptív ({mode}, szabályzat: {policy}) — cél ≈ {target, number} token ({contextLimit, number} tokenes ablakhoz)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Válaszformáló utasítások beillesztése a szolgáltató kimenetének átírása nélkül. Szabadon kombinálható.", "mcpAccessibilityDescription": "Hatókörbe foglalja az MCP-eszközök kimeneteit (külön tároló).", "compressionStylesTileSummary": "{tokens, number} token megtakarítva · {runs, plural, one {# stílusozott futtatás} other {# stílusozott futtatás}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Egészség", "cliproxyapiPort": "Port", "qdrantHost": "Gazda", - "qdrantCollection": "Gyűjtemény" + "qdrantCollection": "Gyűjtemény", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index b633c75627..55ff796e72 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL Ikon", "iconUrlHint": "Opsional. URL gambar yang ditampilkan sebagai ikon penyedia ini.", "iconUrlInvalid": "URL ikon tidak valid. Gunakan URL http(s):// atau data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Buat kunci API di https://app.requesty.ai, lalu tempel di sini sebagai token Bearer. Endpoint yang kompatibel dengan OpenAI di https://router.requesty.ai/v1, dengan katalog /v1/models langsung.", "runwayml": "Pembuatan video Runway berbasis tugas. OmniRoute mengirimkan pekerjaan text-to-video atau image-to-video, melakukan polling pada /v1/tasks/[id], dan menormalisasi output video yang selesai kembali ke respons /v1/videos/generations yang mirip OpenAI.", "sambanova": "Kredit gratis $5 saat pendaftaran (validitas 30 hari), tidak memerlukan kartu kredit", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Penemuan model menggunakan /v2/lm/scenarios/foundation-models/models pada AI_API_URL. Permintaan chat menggunakan deploymentUrl/chat/completions dan memerlukan AI-Resource-Group.", "sarvam": "Sarvam AI kompatibel dengan OpenAI di /v1. OmniRoute memeriksa /v1/models dan mengarahkan lalu lintas obrolan ke /v1/chat/completions. Model disesuaikan untuk bahasa-bahasa India.", "scaleway": "1 juta token gratis untuk akun baru — patuh EU/GDPR (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "mode: {mode}", "compressionAdaptiveOff": "Anggaran konteks adaptif: nonaktif (pemicu otomatis warisan)", "compressionAdaptiveTarget": "Adaptif ({mode}, kebijakan: {policy}) — target ≈ {target, number} token (untuk jendela {contextLimit, number}-token)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Suntikkan instruksi pembentukan respons tanpa menulis ulang output penyedia. Kombinasikan secara bebas.", "mcpAccessibilityDescription": "Membatasi cakupan output alat MCP (penyimpanan terpisah).", "compressionStylesTileSummary": "{tokens, number} token disimpan · {runs, plural, one {# eksekusi digayakan} other {# eksekusi digayakan}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Kesehatan", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Koleksi" + "qdrantCollection": "Koleksi", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index 9e0d7bf60c..62011e3b1d 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL Icona", "iconUrlHint": "Opzionale. URL dell'immagine mostrata come icona di questo provider.", "iconUrlInvalid": "URL dell'icona non valida. Usa un URL http(s):// o data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Crea una chiave API su https://app.requesty.ai, quindi incollala qui come token Bearer. Endpoint compatibile con OpenAI su https://router.requesty.ai/v1, con un catalogo /v1/models in tempo reale.", "runwayml": "La generazione video di Runway è basata su task. OmniRoute invia lavori text-to-video o image-to-video, interroga /v1/tasks/[id] e normalizza gli output video completati nella risposta simile a OpenAI /v1/videos/generations.", "sambanova": "$5 di crediti gratuiti alla registrazione (validità 30 giorni), nessuna carta di credito richiesta", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "La scoperta dei modelli utilizza /v2/lm/scenarios/foundation-models/models su AI_API_URL. Le richieste di chat utilizzano deploymentUrl/chat/completions e richiedono AI-Resource-Group.", "sarvam": "Sarvam AI è compatibile con OpenAI su /v1. OmniRoute controlla /v1/models e instrada il traffico chat verso /v1/chat/completions. I modelli sono ottimizzati per le lingue indiane.", "scaleway": "1M di token gratuiti per i nuovi account — conforme a UE/GDPR (Parigi), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "modalità: {mode}", "compressionAdaptiveOff": "Budget del contesto adattivo: disattivato (attivazione automatica legacy)", "compressionAdaptiveTarget": "Adattivo ({mode}, criterio: {policy}) — target ≈ {target, number} token (per una finestra di {contextLimit, number} token)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Inserisci istruzioni per modellare la risposta senza riscrivere l'output del provider. Combina liberamente.", "mcpAccessibilityDescription": "Limita l'ambito degli output degli strumenti MCP (archivio separato).", "compressionStylesTileSummary": "{tokens, number} token risparmiati · {runs, plural, one {# esecuzione stilizzata} other {# esecuzioni stilizzate}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Salute", "cliproxyapiPort": "Porta", "qdrantHost": "Host", - "qdrantCollection": "Collezione" + "qdrantCollection": "Collezione", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 5510246fbb..8e0241f107 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "アイコンURL", "iconUrlHint": "任意。このプロバイダーのアイコンとして表示される画像URL。", "iconUrlInvalid": "無効なアイコンURLです。http(s)://またはdata:image/*;base64 URLを使用してください。", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.aiでAPIキーを作成し、ここにBearerトークンとして貼り付けます。https://router.requesty.ai/v1にあるOpenAI互換のエンドポイントは、有効な/v1/modelsカタログを提供します。", "runwayml": "Runwayの動画生成はタスクベースです。OmniRouteはtext-to-videoまたはimage-to-videoジョブを送信し、/v1/tasks/[id]をポーリングして、完了した動画出力をOpenAI風の/v1/videos/generationsレスポンスに正規化して戻します。", "sambanova": "新規登録時に$5分の無料クレジット(30日間有効)、クレジットカード不要", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "モデルの検出はAI_API_URL上の/v2/lm/scenarios/foundation-models/modelsを使用します。チャットリクエストはdeploymentUrl/chat/completionsを使用し、AI-Resource-Groupが必要です。", "sarvam": "Sarvam AIは/v1でOpenAI互換です。OmniRouteは/v1/modelsをプローブし、チャットトラフィックを/v1/chat/completionsにルーティングします。モデルはインド系言語に調整されています。", "scaleway": "新規アカウント向けに100万無料トークン — EU/GDPR準拠(パリ)、Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "モード: {mode}", "compressionAdaptiveOff": "アダプティブコンテキストバジェット: オフ(レガシー自動トリガー)", "compressionAdaptiveTarget": "アダプティブ({mode}、ポリシー: {policy})— ターゲット ≈ {target, number} トークン({contextLimit, number} トークンウィンドウ用)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "プロバイダーの出力を書き換えることなく、レスポンス整形指示を挿入します。自由に組み合わせ可能です。", "mcpAccessibilityDescription": "MCPツールの出力をスコープします(別ストア)。", "compressionStylesTileSummary": "{tokens, number} トークン節約 · {runs, plural, one {# 回の実行にスタイル適用} other {# 回の実行にスタイル適用}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "健康", "cliproxyapiPort": "ポート", "qdrantHost": "ホスト", - "qdrantCollection": "コレクション" + "qdrantCollection": "コレクション", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index fc00c8fa7d..3632fa676c 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "아이콘 URL", "iconUrlHint": "선택 사항. 이 제공자의 아이콘으로 표시될 이미지 URL입니다.", "iconUrlInvalid": "잘못된 아이콘 URL입니다. http(s):// 또는 data:image/*;base64 URL을 사용하세요.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai에서 API 키를 생성한 후 여기에 Bearer 토큰으로 붙여넣으세요. https://router.requesty.ai/v1의 OpenAI 호환 엔드포인트는 실시간 /v1/models 카탈로그를 제공합니다.", "runwayml": "Runway 비디오 생성은 작업 기반입니다. OmniRoute는 텍스트-비디오 또는 이미지-비디오 작업을 제출하고, /v1/tasks/[id]를 폴링하며, 완료된 비디오 출력을 OpenAI 스타일의 /v1/videos/generations 응답으로 정규화합니다.", "sambanova": "가입 시 $5 무료 크레딧 제공(유효기간 30일), 신용카드 불필요", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "모델 검색은 AI_API_URL의 /v2/lm/scenarios/foundation-models/models를 사용합니다. 채팅 요청은 deploymentUrl/chat/completions를 사용하며 AI-Resource-Group이 필요합니다.", "sarvam": "Sarvam AI는 /v1에서 OpenAI와 호환됩니다. OmniRoute는 /v1/models를 탐색하고 채팅 트래픽을 /v1/chat/completions로 라우팅합니다. 모델은 인도 언어에 맞게 조정되었습니다.", "scaleway": "신규 계정 대상 1M 무료 토큰 — EU/GDPR 준수(파리), Qwen3 235B 및 Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "모드: {mode}", "compressionAdaptiveOff": "적응형 컨텍스트 예산: 꺼짐 (기존 자동 트리거)", "compressionAdaptiveTarget": "적응형 ({mode}, 정책: {policy}) — 대상 ≈ {target, number} 토큰 ({contextLimit, number} 토큰 창 기준)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "제공자 출력을 다시 작성하지 않고 응답 형성 지침을 주입합니다. 자유롭게 조합하세요.", "mcpAccessibilityDescription": "MCP 도구 출력의 범위를 제한합니다(별도 저장소).", "compressionStylesTileSummary": "{tokens, number} 토큰 절약됨 · {runs, plural, one {#개 실행 스타일 지정됨} other {#개 실행 스타일 지정됨}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "건강", "cliproxyapiPort": "포트", "qdrantHost": "호스트", - "qdrantCollection": "컬렉션" + "qdrantCollection": "컬렉션", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index 0eab32efd9..69266b52d2 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "आयकॉन URL", "iconUrlHint": "पर्यायी. या प्रदात्याचा आयकॉन म्हणून दर्शविलेली इमेज URL.", "iconUrlInvalid": "अवैध आयकॉन URL. http(s):// किंवा data:image/*;base64 URL वापरा.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai वर API की तयार करा, नंतर ती येथे Bearer टोकन म्हणून पेस्ट करा. https://router.requesty.ai/v1 वर OpenAI-सुसंगत एंडपॉइंट आहे, ज्यामध्ये थेट /v1/models कॅटलॉग उपलब्ध आहे.", "runwayml": "Runway व्हिडिओ निर्मिती ही टास्क-आधारित आहे. OmniRoute हे text-to-video किंवा image-to-video जॉब्स सबमिट करते, /v1/tasks/[id] पोल करते आणि पूर्ण झालेल्या व्हिडिओ आउटपुटला पुन्हा OpenAI-सारख्या /v1/videos/generations प्रतिसादात सामान्य करते.", "sambanova": "साइनअपवर $5 मोफत क्रेडिट्स (30 दिवसांची वैधता), क्रेडिट कार्डची आवश्यकता नाही", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "मॉडेल शोध AI_API_URL वरील /v2/lm/scenarios/foundation-models/models वापरतो. चॅट विनंत्या deploymentUrl/chat/completions वापरतात आणि त्यासाठी AI-Resource-Group आवश्यक आहे.", "sarvam": "Sarvam AI हे OpenAI-संगत आहे /v1. OmniRoute /v1/models चा शोध घेतो आणि चॅट ट्रॅफिक /v1/chat/completions कडे मार्गदर्शित करतो. मॉडेल्स भारतीय भाषांसाठी ट्यून केलेले आहेत.", "scaleway": "नवीन खात्यांसाठी 1M मोफत टोकन्स — EU/GDPR सुसंगत (पॅरिस), Qwen3 235B आणि Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "मोड: {mode}", "compressionAdaptiveOff": "अ‍ॅडॉप्टिव्ह संदर्भ बजेट: बंद (लेगसी ऑटो-ट्रिगर)", "compressionAdaptiveTarget": "अ‍ॅडॉप्टिव्ह ({mode}, पॉलिसी: {policy}) — लक्ष्य ≈ {target, number} टोकन्स ({contextLimit, number}-टोकन विंडोसाठी)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "प्रोव्हाइडर आउटपुट पुन्हा न लिहिता रिस्पॉन्स-शेपिंग सूचना इंजेक्ट करा. मुक्तपणे एकत्र करा.", "mcpAccessibilityDescription": "MCP टूल आउटपुटची व्याप्ती ठरवते (स्वतंत्र स्टोअर).", "compressionStylesTileSummary": "{tokens, number} टोकन्स वाचवले · {runs, plural, one {# रन स्टाईल केला} other {# रन्स स्टाईल केले}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "आरोग्य", "cliproxyapiPort": "पोर्ट", "qdrantHost": "होस्ट", - "qdrantCollection": "संग्रह" + "qdrantCollection": "संग्रह", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 9e4ed91cae..c6551e71e6 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL Ikon", "iconUrlHint": "Pilihan. URL imej yang ditunjukkan sebagai ikon penyedia ini.", "iconUrlInvalid": "URL ikon tidak sah. Gunakan URL http(s):// atau data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Cipta kunci API di https://app.requesty.ai, kemudian tampalkannya di sini sebagai token Bearer. Titik akhir serasi OpenAI di https://router.requesty.ai/v1, dengan katalog /v1/models langsung.", "runwayml": "Penjanaan video Runway adalah berasaskan tugas. OmniRoute menyerahkan kerja teks-ke-video atau imej-ke-video, meninjau /v1/tasks/[id], dan menormalkan output video yang telah selesai kembali ke dalam respons /v1/videos/generations seperti OpenAI.", "sambanova": "Kredit percuma $5 semasa pendaftaran (tempoh sah 30 hari), tiada kad kredit diperlukan", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Penemuan model menggunakan /v2/lm/scenarios/foundation-models/models pada AI_API_URL. Permintaan sembang menggunakan deploymentUrl/chat/completions dan memerlukan AI-Resource-Group.", "sarvam": "Sarvam AI adalah serasi dengan OpenAI pada /v1. OmniRoute menyiasat /v1/models dan mengarahkan trafik sembang ke /v1/chat/completions. Model disesuaikan untuk bahasa-bahasa India.", "scaleway": "1M token percuma untuk akaun baharu — mematuhi EU/GDPR (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "mod: {mode}", "compressionAdaptiveOff": "Belanjawan konteks adaptif: mati (pencetus automatik legasi)", "compressionAdaptiveTarget": "Adaptif ({mode}, dasar: {policy}) — sasaran ≈ {target, number} token (untuk tetingkap {contextLimit, number}-token)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Suntik arahan pembentukan respons tanpa menulis semula output penyedia. Gabungkan secara bebas.", "mcpAccessibilityDescription": "Menskupkan output alat MCP (storan berasingan).", "compressionStylesTileSummary": "{tokens, number} token dijimatkan · {runs, plural, one {# larian digayakan} other {# larian digayakan}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Kesihatan", "cliproxyapiPort": "Pelabuhan", "qdrantHost": "Hos", - "qdrantCollection": "Koleksi" + "qdrantCollection": "Koleksi", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index 1915ce7863..c249b7a331 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Pictogram-URL", "iconUrlHint": "Optioneel. Afbeeldings-URL die wordt getoond als het pictogram van deze provider.", "iconUrlInvalid": "Ongeldige pictogram-URL. Gebruik een http(s):// of data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Maak een API-sleutel aan op https://app.requesty.ai en plak deze hier als een Bearer-token. OpenAI-compatibel eindpunt op https://router.requesty.ai/v1, met een live /v1/models-catalogus.", "runwayml": "Runway-videogeneratie is taakgebaseerd. OmniRoute dient text-to-video- of image-to-video-taken in, peilt /v1/tasks/[id] en normaliseert de voltooide video-uitvoer terug naar het OpenAI-achtige /v1/videos/generations-antwoord.", "sambanova": "$5 gratis tegoed bij aanmelding (30 dagen geldig), geen creditcard vereist", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Modeldetectie gebruikt /v2/lm/scenarios/foundation-models/models op AI_API_URL. Chatverzoeken gebruiken deploymentUrl/chat/completions en vereisen AI-Resource-Group.", "sarvam": "Sarvam AI is OpenAI-compatibel op /v1. OmniRoute onderzoekt /v1/models en leidt chatverkeer naar /v1/chat/completions. Modellen zijn afgestemd op Indic-talen.", "scaleway": "1M gratis tokens voor nieuwe accounts — EU/AVG-conform (Parijs), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "modus: {mode}", "compressionAdaptiveOff": "Adaptief contextbudget: uit (legacy auto-trigger)", "compressionAdaptiveTarget": "Adaptief ({mode}, beleid: {policy}) — doel ≈ {target, number} tokens (voor een venster van {contextLimit, number} tokens)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injecteer instructies voor responsvormgeving zonder de uitvoer van de provider te herschrijven. Vrij te combineren.", "mcpAccessibilityDescription": "Beperkt de scope van MCP-tooluitvoer (afzonderlijke opslag).", "compressionStylesTileSummary": "{tokens, number} tokens bespaard · {runs, plural, one {# run gestyled} other {# runs gestyled}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Gezondheid", "cliproxyapiPort": "Haven", "qdrantHost": "Host", - "qdrantCollection": "Verzameling" + "qdrantCollection": "Verzameling", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 23a0830465..526925270e 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Ikon-URL", "iconUrlHint": "Valgfritt. Bilde-URL som vises som denne leverandørens ikon.", "iconUrlInvalid": "Ugyldig ikon-URL. Bruk en http(s):// eller data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Opprett en API-nøkkel på https://app.requesty.ai, og lim den deretter inn her som et Bearer-token. OpenAI-kompatibelt endepunkt på https://router.requesty.ai/v1, med en live /v1/models-katalog.", "runwayml": "Runway-videogenerering er oppgavebasert. OmniRoute sender inn tekst-til-video- eller bilde-til-video-jobber, poller /v1/tasks/[id], og normaliserer de ferdige videoresultatene tilbake til den OpenAI-lignende /v1/videos/generations-responsen.", "sambanova": "$5 gratis kreditt ved registrering (30 dagers gyldighet), ikke krav om kredittkort", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Modellsøk bruker /v2/lm/scenarios/foundation-models/models på AI_API_URL. Chat-forespørsler bruker deploymentUrl/chat/completions og krever AI-Resource-Group.", "sarvam": "Sarvam AI er OpenAI-kompatibel på /v1. OmniRoute undersøker /v1/models og ruter chat-trafikk til /v1/chat/completions. Modeller er tilpasset for indiske språk.", "scaleway": "1M gratis tokens for nye kontoer — EU/GDPR-kompatibel (Paris), Qwen3 235B og Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "modus: {mode}", "compressionAdaptiveOff": "Adaptivt kontekstbudsjett: av (foreldet automatisk utløser)", "compressionAdaptiveTarget": "Adaptiv ({mode}, policy: {policy}) — mål ≈ {target, number} tokener (for et vindu på {contextLimit, number} tokener)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Sett inn instruksjoner for responsforming uten å omskrive leverandørutdata. Kombiner fritt.", "mcpAccessibilityDescription": "Avgrenser MCP-verktøyutdata (eget lager).", "compressionStylesTileSummary": "{tokens, number} tokener spart · {runs, plural, one {# kjøring stiltilpasset} other {# kjøringer stiltilpasset}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Helse", "cliproxyapiPort": "Port", "qdrantHost": "Vert", - "qdrantCollection": "Samling" + "qdrantCollection": "Samling", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index f2f6e93840..d2148459b5 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ng Icon", "iconUrlHint": "Opsyonal. URL ng larawan na ipinapakita bilang icon ng provider na ito.", "iconUrlInvalid": "Hindi wastong URL ng icon. Gumamit ng http(s):// o data:image/*;base64 na URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Gumawa ng API key sa https://app.requesty.ai, pagkatapos ay i-paste ito rito bilang isang Bearer token. OpenAI-compatible na endpoint sa https://router.requesty.ai/v1, na may live na catalog ng /v1/models.", "runwayml": "Ang pagbuo ng video sa Runway ay task-based. Nagpapasa ang OmniRoute ng mga text-to-video o image-to-video na job, nagpo-poll sa /v1/tasks/[id], at nino-normalize ang mga natapos na video output pabalik sa OpenAI-like na tugon ng /v1/videos/generations.", "sambanova": "$5 na libreng credits sa pag-signup (may bisa sa loob ng 30 araw), walang kinakailangang credit card", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Gumagamit ang pagtuklas ng modelo ng /v2/lm/scenarios/foundation-models/models sa AI_API_URL. Gumagamit ang mga kahilingan sa chat ng deploymentUrl/chat/completions at nangangailangan ng AI-Resource-Group.", "sarvam": "Ang Sarvam AI ay katugma ng OpenAI sa /v1. Ang OmniRoute ay nag-uusisa sa /v1/models at nagruruta ng chat traffic sa /v1/chat/completions. Ang mga modelo ay na-tune para sa mga wikang Indic.", "scaleway": "1M libreng token para sa mga bagong account — sumusunod sa EU/GDPR (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "mode: {mode}", "compressionAdaptiveOff": "Adaptive context budget: naka-off (legacy na auto-trigger)", "compressionAdaptiveTarget": "Adaptive ({mode}, patakaran: {policy}) — target ≈ {target, number} na token (para sa isang {contextLimit, number}-token na window)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Mag-inject ng mga tagubilin sa paghubog ng tugon nang hindi muling isinusulat ang output ng provider. Malayang pagsamahin.", "mcpAccessibilityDescription": "Nililimitahan ang mga output ng MCP tool (hiwalay na store).", "compressionStylesTileSummary": "{tokens, number} na token ang na-save · {runs, plural, one {# run ang na-style} other {# na run ang na-style}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Kalusugan", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Koleksyon" + "qdrantCollection": "Koleksyon", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/pl.json b/src/i18n/messages/pl.json index df09182fa7..6d52f19b40 100644 --- a/src/i18n/messages/pl.json +++ b/src/i18n/messages/pl.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ikony", "iconUrlHint": "Opcjonalnie. URL obrazu wyświetlany jako ikona tego provider.", "iconUrlInvalid": "Nieprawidłowy adres URL ikony. Użyj adresu http(s):// lub data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Utwórz klucz API na https://app.requesty.ai, a następnie wklej go tutaj jako token Bearer. Punkt końcowy zgodny z OpenAI pod adresem https://router.requesty.ai/v1, z aktywnym katalogiem /v1/models.", "runwayml": "Generowanie wideo w Runway opiera się na zadaniach. OmniRoute przesyła zadania text-to-video lub image-to-video, odpytuje /v1/tasks/[id] i normalizuje gotowe wyniki wideo z powrotem do odpowiedzi w stylu OpenAI /v1/videos/generations.", "sambanova": "$5 darmowych kredytów przy rejestracji (ważność 30 dni), karta kredytowa nie jest wymagana", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Wykrywanie modeli używa /v2/lm/scenarios/foundation-models/models na AI_API_URL. Żądania czatu używają deploymentUrl/chat/completions i wymagają AI-Resource-Group.", "sarvam": "Sarvam AI jest zgodny z OpenAI na /v1. OmniRoute bada /v1/models i kieruje ruch czatu do /v1/chat/completions. Modele są dostosowane do języków indyjskich.", "scaleway": "1M darmowych tokenów dla nowych kont — zgodność z UE/RODO (Paryż), Qwen3 235B i Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "tryb: {mode}", "compressionAdaptiveOff": "Adaptacyjny budżet kontekstu: wył. (starszy automatyczny wyzwalacz)", "compressionAdaptiveTarget": "Adaptacyjny ({mode}, polityka: {policy}) — cel ≈ {target, number} tokenów (dla okna o rozmiarze {contextLimit, number} tokenów)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Wstrzykuj instrukcje kształtujące odpowiedź bez przepisywania danych wyjściowych dostawcy. Łącz dowolnie.", "mcpAccessibilityDescription": "Ogranicza zakres danych wyjściowych narzędzi MCP (osobny magazyn).", "compressionStylesTileSummary": "{tokens, number} tokenów zaoszczędzonych · {runs, plural, one {# przebieg ostylowany} few {# przebiegi ostylowane} many {# przebiegów ostylowanych} other {# przebiegów ostylowanych}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Zdrowie", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Kolekcja" + "qdrantCollection": "Kolekcja", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "Silnik RTK", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index bc224f2ebf..5a8da854e4 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5068,6 +5068,9 @@ "importFromFileImporting": "Importando…", "importFromFileImport": "Importar {count} provedores", "importFromFileResult": "{success} provedores importados ({failed} falharam)", + "importFromFileDownloadTemplate": "Baixar modelo CSV", + "importFromFileMoreErrors": "+{count} mais", + "importFromFileSchemaHint": "As colunas CSV são posicionais: provider, name, apiKey, baseUrl (opcional), priority (opcional). A coluna provider deve ser o id de um provedor gerenciado existente ou o id de um nó compatível com OpenAI/Anthropic já registrado — esta importação não cria novos nós de endpoint.", "adaptaTutorial": { "title": "Como conectar o Adapta Web", "introPrefix": "Adapta autentica através do Clerk. O token", @@ -5249,6 +5252,10 @@ "iconUrlLabel": "URL do ícone", "iconUrlHint": "Opcional. URL da imagem exibida como ícone deste provedor.", "iconUrlInvalid": "URL do ícone inválido. Use um URL http(s):// ou data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6240,6 +6247,7 @@ "requesty": "Crie uma chave de API em https://app.requesty.ai e cole-a aqui como um token Bearer. Endpoint compatível com OpenAI em https://router.requesty.ai/v1, com um catálogo /v1/models ao vivo.", "runwayml": "A geração de vídeo do Runway é baseada em tarefas. O OmniRoute envia jobs de texto-para-vídeo ou imagem-para-vídeo, consulta /v1/tasks/[id] periodicamente e normaliza as saídas de vídeo concluídas de volta para a resposta /v1/videos/generations no estilo OpenAI.", "sambanova": "$5 em créditos gratuitos no cadastro (validade de 30 dias), sem necessidade de cartão de crédito", + "seekai": "Crie uma chave de API em https://seekai.cc e cole aqui como Bearer token. URL base compatível com OpenAI: https://seekai.cc/v1.", "sap": "A descoberta de modelos usa /v2/lm/scenarios/foundation-models/models em AI_API_URL. As solicitações de chat usam deploymentUrl/chat/completions e requerem AI-Resource-Group.", "sarvam": "O Sarvam AI é compatível com OpenAI em /v1. O OmniRoute sonda /v1/models e roteia o tráfego de chat para /v1/chat/completions. Os modelos são ajustados para línguas indianas.", "scaleway": "1M tokens gratuitos para novas contas — compatível com UE/GDPR (Paris), Qwen3 235B e Llama 70B", @@ -7813,6 +7821,14 @@ "compressionDerivedMode": "modo: {mode}", "compressionAdaptiveOff": "Orçamento de contexto adaptativo: desligado (auto-gatilho legado)", "compressionAdaptiveTarget": "Adaptativo ({mode}, política: {policy}) — alvo ≈ {target, number} tokens (para uma janela de {contextLimit, number} tokens)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injeta instruções de modelagem de resposta sem reescrever a saída do provedor. Combine livremente.", "mcpAccessibilityDescription": "Delimita o escopo das saídas de ferramentas MCP (armazenamento separado).", "compressionStylesTileSummary": "{tokens, number} tokens economizados · {runs, plural, one {# execução estilizada} other {# execuções estilizadas}}", @@ -8341,7 +8357,15 @@ "cliproxyapiHealth": "Saúde", "cliproxyapiPort": "Porta", "qdrantHost": "Host", - "qdrantCollection": "Coleção" + "qdrantCollection": "Coleção", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "Motor RTK", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4c4e0cd838..6c817fca70 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL do ícone", "iconUrlHint": "Opcional. URL da imagem mostrada como ícone deste provedor.", "iconUrlInvalid": "URL do ícone inválido. Utilize uma URL http(s):// ou data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Crie uma chave de API em https://app.requesty.ai e, em seguida, cole-a aqui como um token Bearer. Endpoint compatível com a OpenAI em https://router.requesty.ai/v1, com um catálogo /v1/models em tempo real.", "runwayml": "A geração de vídeo da Runway é baseada em tarefas. O OmniRoute submete tarefas de texto para vídeo ou imagem para vídeo, consulta /v1/tasks/[id] e normaliza as saídas de vídeo concluídas de volta para a resposta /v1/videos/generations semelhante à da OpenAI.", "sambanova": "$5 em créditos gratuitos no registo (validade de 30 dias), sem necessidade de cartão de crédito", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "A descoberta de modelos utiliza /v2/lm/scenarios/foundation-models/models em AI_API_URL. Os pedidos de chat utilizam deploymentUrl/chat/completions e requerem AI-Resource-Group.", "sarvam": "Sarvam AI é compatível com OpenAI em /v1. OmniRoute investiga /v1/models e direciona o tráfego de chat para /v1/chat/completions. Os modelos são ajustados para línguas índicas.", "scaleway": "1M de tokens gratuitos para novas contas — em conformidade com a UE/RGPD (Paris), Qwen3 235B e Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "modo: {mode}", "compressionAdaptiveOff": "Orçamento de contexto adaptativo: desativado (acionamento automático legado)", "compressionAdaptiveTarget": "Adaptativo ({mode}, política: {policy}) — alvo ≈ {target, number} tokens (para uma janela de {contextLimit, number} tokens)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injete instruções de modelação de resposta sem reescrever a saída do fornecedor. Combine livremente.", "mcpAccessibilityDescription": "Delimita as saídas das ferramentas MCP (armazenamento separado).", "compressionStylesTileSummary": "{tokens, number} tokens poupados · {runs, plural, one {# execução estilizada} other {# execuções estilizadas}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Saúde", "cliproxyapiPort": "Porto", "qdrantHost": "Anfitrião", - "qdrantCollection": "Coleção" + "qdrantCollection": "Coleção", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "Motor RTK", diff --git a/src/i18n/messages/ro.json b/src/i18n/messages/ro.json index 1e980349c4..1a71607174 100644 --- a/src/i18n/messages/ro.json +++ b/src/i18n/messages/ro.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL pictogramă", "iconUrlHint": "Opțional. URL-ul imaginii afișate ca pictogramă a acestui furnizor.", "iconUrlInvalid": "URL-ul iconului este invalid. Folosiți un URL http(s):// sau data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Creează o cheie API la https://app.requesty.ai, apoi lipește-o aici ca token Bearer. Endpoint compatibil cu OpenAI la https://router.requesty.ai/v1, cu un catalog /v1/models live.", "runwayml": "Generarea video Runway este bazată pe sarcini. OmniRoute trimite lucrări text-to-video sau image-to-video, interoghează periodic /v1/tasks/[id] și normalizează ieșirile video finalizate înapoi în răspunsul de tip OpenAI /v1/videos/generations.", "sambanova": "$5 credite gratuite la înregistrare (valabilitate 30 de zile), nu este necesar un card de credit", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Descoperirea modelelor folosește /v2/lm/scenarios/foundation-models/models pe AI_API_URL. Solicitările de chat folosesc deploymentUrl/chat/completions și necesită AI-Resource-Group.", "sarvam": "Sarvam AI este compatibil cu OpenAI pe /v1. OmniRoute probează /v1/models și direcționează traficul de chat către /v1/chat/completions. Modelele sunt ajustate pentru limbile indic.", "scaleway": "1M tokenuri gratuite pentru conturi noi — conformitate UE/GDPR (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "mod: {mode}", "compressionAdaptiveOff": "Buget de context adaptiv: dezactivat (declanșare automată moștenită)", "compressionAdaptiveTarget": "Adaptiv ({mode}, politică: {policy}) — țintă ≈ {target, number} tokenuri (pentru o fereastră de {contextLimit, number} tokenuri)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Injectează instrucțiuni de modelare a răspunsului fără a rescrie ieșirea furnizorului. Combină liber.", "mcpAccessibilityDescription": "Limitează domeniul de aplicare al ieșirilor instrumentelor MCP (stocare separată).", "compressionStylesTileSummary": "{tokens, number} tokenuri salvate · {runs, plural, one {# rulare stilizată} few {# rulări stilizate} other {# de rulări stilizate}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Sănătate", "cliproxyapiPort": "Port", "qdrantHost": "Gazdă", - "qdrantCollection": "Colecție" + "qdrantCollection": "Colecție", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ru.json b/src/i18n/messages/ru.json index 51e6d4c468..e7b0db41f1 100644 --- a/src/i18n/messages/ru.json +++ b/src/i18n/messages/ru.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL-адрес иконки", "iconUrlHint": "Необязательно. URL-адрес изображения, используемого в качестве иконки этого провайдера.", "iconUrlInvalid": "Неверный URL значка. Используйте URL-адрес http(s):// или data:image/*;base64.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-прод", "openaiPrefixPlaceholder": "oc-прод", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Создайте API-ключ на https://app.requesty.ai, затем вставьте его сюда в качестве Bearer-токена. Совместимая с OpenAI конечная точка находится по адресу https://router.requesty.ai/v1, с живым каталогом /v1/models.", "runwayml": "Генерация видео в Runway основана на задачах. OmniRoute отправляет задания text-to-video или image-to-video, опрашивает /v1/tasks/[id] и нормализует готовые видеовыходы обратно в ответ /v1/videos/generations, аналогичный OpenAI.", "sambanova": "$5 бесплатных кредитов при регистрации (срок действия 30 дней), кредитная карта не требуется", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Обнаружение моделей использует /v2/lm/scenarios/foundation-models/models на AI_API_URL. Запросы чата используют deploymentUrl/chat/completions и требуют AI-Resource-Group.", "sarvam": "Sarvam AI — (sarvam) — Индийские языковые AI модели", "scaleway": "1 млн бесплатных токенов для новых аккаунтов — соответствие EU/GDPR (Париж), Qwen3 235B и Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "режим: {mode}", "compressionAdaptiveOff": "Адаптивный бюджет контекста: выкл. (устаревший автотриггер)", "compressionAdaptiveTarget": "Адаптивный ({mode}, политика: {policy}) — цель ≈ {target, number} токенов (для окна в {contextLimit, number} токенов)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Внедряйте инструкции по формированию ответов без перезаписи вывода провайдера. Комбинируйте свободно.", "mcpAccessibilityDescription": "Ограничивает область вывода инструментов MCP (отдельное хранилище).", "compressionStylesTileSummary": "{tokens, number} токенов сэкономлено · {runs, plural, one {# запуск стилизован} few {# запуска стилизовано} many {# запусков стилизовано} other {# запуска стилизовано}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Здоровье", "cliproxyapiPort": "Порт", "qdrantHost": "Хост", - "qdrantCollection": "Коллекция" + "qdrantCollection": "Коллекция", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/sk.json b/src/i18n/messages/sk.json index 3801642d55..a73a6362c8 100644 --- a/src/i18n/messages/sk.json +++ b/src/i18n/messages/sk.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ikony", "iconUrlHint": "Voliteľné. URL obrázka zobrazeného ako ikona tohto poskytovateľa.", "iconUrlInvalid": "Neplatná URL ikony. Použite http(s):// alebo data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Vytvorte API kľúč na https://app.requesty.ai, potom ho sem vložte ako Bearer token. Koncový bod kompatibilný s OpenAI na https://router.requesty.ai/v1, so živým katalógom /v1/models.", "runwayml": "Generovanie videa v Runway je založené na úlohách. OmniRoute odosiela úlohy text-to-video alebo image-to-video, dopytuje sa na /v1/tasks/[id] a normalizuje hotové video výstupy späť do odpovede /v1/videos/generations podobnej OpenAI.", "sambanova": "Bezplatný kredit 5 $ pri registrácii (platnosť 30 dní), nevyžaduje sa žiadna kreditná karta", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Vyhľadávanie modelov používa /v2/lm/scenarios/foundation-models/models na AI_API_URL. Požiadavky na chat používajú deploymentUrl/chat/completions a vyžadujú AI-Resource-Group.", "sarvam": "Sarvam AI je kompatibilný s OpenAI na /v1. OmniRoute skúma /v1/models a smeruje chatový prenos na /v1/chat/completions. Modely sú optimalizované pre indické jazyky.", "scaleway": "1M bezplatných tokenov pre nové účty — v súlade s EÚ/GDPR (Paríž), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "režim: {mode}", "compressionAdaptiveOff": "Adaptívny rozpočet kontextu: vypnuté (starší automatický spúšťač)", "compressionAdaptiveTarget": "Adaptívny ({mode}, politika: {policy}) — cieľ ≈ {target, number} tokenov (pre okno s {contextLimit, number} tokenmi)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Vložiť inštrukcie na formovanie odpovede bez prepisovania výstupu poskytovateľa. Voľne kombinujte.", "mcpAccessibilityDescription": "Obmedzuje rozsah výstupov nástrojov MCP (samostatné úložisko).", "compressionStylesTileSummary": "{tokens, number} tokenov ušetrených · {runs, plural, one {# štylizované spustenie} few {# štylizované spustenia} other {# štylizovaných spustení}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Zdravie", "cliproxyapiPort": "Port", "qdrantHost": "Host", - "qdrantCollection": "Zbierka" + "qdrantCollection": "Zbierka", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/sv.json b/src/i18n/messages/sv.json index 7aeec50876..f5ac7aa344 100644 --- a/src/i18n/messages/sv.json +++ b/src/i18n/messages/sv.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Ikon-URL", "iconUrlHint": "Valfritt. Bild-URL som visas som denna leverantörs ikon.", "iconUrlInvalid": "Ogiltig ikon-URL. Använd en http(s):// eller data:image/*;base64-URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Skapa en API-nyckel på https://app.requesty.ai, klistra sedan in den här som en Bearer-token. OpenAI-kompatibel slutpunkt på https://router.requesty.ai/v1, med en live /v1/models-katalog.", "runwayml": "Runway-videogenerering är uppgiftsbaserad. OmniRoute skickar in text-till-video- eller bild-till-video-jobb, pollar /v1/tasks/[id] och normaliserar de färdiga videoutdata tillbaka till det OpenAI-liknande /v1/videos/generations-svaret.", "sambanova": "$5 i gratis kredit vid registrering (30 dagars giltighet), inget kreditkort krävs", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Modellidentifiering använder /v2/lm/scenarios/foundation-models/models på AI_API_URL. Chatförfrågningar använder deploymentUrl/chat/completions och kräver AI-Resource-Group.", "sarvam": "Sarvam AI är OpenAI-kompatibel på /v1. OmniRoute undersöker /v1/models och dirigerar chatttrafik till /v1/chat/completions. Modellerna är anpassade för indiska språk.", "scaleway": "1M gratis tokens för nya konton — EU/GDPR-kompatibel (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "läge: {mode}", "compressionAdaptiveOff": "Adaptiv kontextbudget: av (äldre auto-trigger)", "compressionAdaptiveTarget": "Adaptiv ({mode}, policy: {policy}) — mål ≈ {target, number} tokens (för ett fönster på {contextLimit, number} tokens)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Infoga instruktioner för svarsformning utan att skriva om leverantörens utdata. Kombinera fritt.", "mcpAccessibilityDescription": "Avgränsar MCP-verktygsutdata (separat lagring).", "compressionStylesTileSummary": "{tokens, number} tokens sparade · {runs, plural, one {# körning stylad} other {# körningar stylade}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Hälsa", "cliproxyapiPort": "Port", "qdrantHost": "Värd", - "qdrantCollection": "Samling" + "qdrantCollection": "Samling", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/sw.json b/src/i18n/messages/sw.json index df6ce9cbd2..907fe20b71 100644 --- a/src/i18n/messages/sw.json +++ b/src/i18n/messages/sw.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ya Aikoni", "iconUrlHint": "Si lazima. URL ya picha inayoonyeshwa kama aikoni ya mtoa huduma huyu.", "iconUrlInvalid": "URL ya ikoni si sahihi. Tumia http(s):// au data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Unda ufunguo wa API kwenye https://app.requesty.ai, kisha ubandike hapa kama tokeni ya Bearer. Endpoint inayooana na OpenAI iko kwenye https://router.requesty.ai/v1, ikiwa na orodha ya moja kwa moja ya /v1/models.", "runwayml": "Uzalishaji wa video wa Runway unategemea kazi. OmniRoute huwasilisha kazi za maandishi-hadi-video au picha-hadi-video, huangalia mara kwa mara /v1/tasks/[id], na kurekebisha matokeo ya video yaliyokamilika kurudi kwenye jibu la /v1/videos/generations linalofanana na OpenAI.", "sambanova": "Salio la bure la $5 unapojisajili (uhalali wa siku 30), hakuna kadi ya mkopo inayohitajika", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Ugunduzi wa miundo hutumia /v2/lm/scenarios/foundation-models/models kwenye AI_API_URL. Maombi ya gumzo hutumia deploymentUrl/chat/completions na yanahitaji AI-Resource-Group.", "sarvam": "Sarvam AI inapatana na OpenAI kwenye /v1. OmniRoute inachunguza /v1/models na kuelekeza trafiki ya mazungumzo kwenye /v1/chat/completions. Mifano imeboreshwa kwa lugha za Kihindi.", "scaleway": "Tokeni 1M za bure kwa akaunti mpya — inatii EU/GDPR (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "hali: {mode}", "compressionAdaptiveOff": "Bajeti ya muktadha inayobadilika: imezimwa (kichochezi cha zamani cha kiotomatiki)", "compressionAdaptiveTarget": "Inayobadilika ({mode}, sera: {policy}) — lengo ≈ tokeni {target, number} (kwa dirisha la tokeni {contextLimit, number})", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Ingiza maagizo ya kuunda majibu bila kuandika upya matokeo ya mtoa huduma. Changanya kwa uhuru.", "mcpAccessibilityDescription": "Inaweka mipaka ya matokeo ya zana ya MCP (hifadhi tofauti).", "compressionStylesTileSummary": "{tokens, number} tokeni zimehifadhiwa · {runs, plural, one {# umewekewa mtindo} other {# imewekewa mtindo}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Afya", "cliproxyapiPort": "Bandari", "qdrantHost": "Mwenyeji", - "qdrantCollection": "Mkusanyiko" + "qdrantCollection": "Mkusanyiko", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/ta.json b/src/i18n/messages/ta.json index db6b847d64..1158df0301 100644 --- a/src/i18n/messages/ta.json +++ b/src/i18n/messages/ta.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "சின்னத்தின் URL", "iconUrlHint": "விருப்பத்திற்குரியது. இந்த வழங்குநரின் சின்னமாக காட்டப்படும் பட URL.", "iconUrlInvalid": "தவறான ஐகான் URL. http(s):// அல்லது data:image/*;base64 URL ஐப் பயன்படுத்தவும்.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai இல் ஒரு API கீயை உருவாக்கி, பின்னர் அதை இங்கே Bearer டோக்கனாக ஒட்டவும். OpenAI-உடன் இணக்கமான எண்ட்பாயிண்ட் https://router.requesty.ai/v1 இல் நேரடி /v1/models பட்டியலுடன் உள்ளது.", "runwayml": "Runway வீடியோ உருவாக்கம் என்பது பணி அடிப்படையிலானது. OmniRoute ஆனது உரை-க்கு-வீடியோ அல்லது படம்-க்கு-வீடியோ பணிகளைச் சமர்ப்பித்து, /v1/tasks/[id] ஐத் தொடர்ந்து சரிபார்த்து, முடிக்கப்பட்ட வீடியோ வெளியீடுகளை மீண்டும் OpenAI போன்ற /v1/videos/generations பதிலுக்கு இயல்பாக்குகிறது.", "sambanova": "பதிவு செய்யும் போது $5 இலவச கிரெடிட்கள் (30 நாட்கள் செல்லுபடியாகும்), கிரெடிட் கார்டு தேவையில்லை", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "மாதிரி கண்டறிதல் ஆனது AI_API_URL இல் /v2/lm/scenarios/foundation-models/models ஐப் பயன்படுத்துகிறது. அரட்டை கோரிக்கைகள் deploymentUrl/chat/completions ஐப் பயன்படுத்துகின்றன மற்றும் AI-Resource-Group தேவைப்படுகிறது.", "sarvam": "Sarvam AI OpenAI-க்கு இணக்கமானது /v1 இல். OmniRoute /v1/models ஐ ஆராய்ந்து /v1/chat/completions க்கு உரையாடல் போக்குவரத்தை வழிமொழிகிறது. மாதிரிகள் இந்திய மொழிகளுக்காக அமைக்கப்பட்டுள்ளது.", "scaleway": "புதிய கணக்குகளுக்கு 1M இலவச டோக்கன்கள் — EU/GDPR இணக்கமானது (பாரிஸ்), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "முறை: {mode}", "compressionAdaptiveOff": "தகவமைப்பு சூழல் பட்ஜெட்: ஆஃப் (பழைய தானியங்கு-தூண்டுதல்)", "compressionAdaptiveTarget": "தகவமைப்பு ({mode}, கொள்கை: {policy}) — இலக்கு ≈ {target, number} டோக்கன்கள் ({contextLimit, number}-டோக்கன் சாளரத்திற்கு)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "வழங்குநரின் வெளியீட்டை மீண்டும் எழுதாமல் பதில்-வடிவமைப்பு வழிமுறைகளை உட்செலுத்தவும். தாராளமாக இணைக்கவும்.", "mcpAccessibilityDescription": "MCP கருவி வெளியீடுகளை வரம்பிற்குள் வைக்கிறது (தனிச் சேமிப்பகம்).", "compressionStylesTileSummary": "{tokens, number} டோக்கன்கள் சேமிக்கப்பட்டன · {runs, plural, one {# இயக்கம் வடிவமைக்கப்பட்டது} other {# இயக்கங்கள் வடிவமைக்கப்பட்டன}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "ஆரோக்கியம்", "cliproxyapiPort": "போர்ட்", "qdrantHost": "விருந்தினர்", - "qdrantCollection": "கலெக்ஷன்" + "qdrantCollection": "கலெக்ஷன்", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/te.json b/src/i18n/messages/te.json index 10fe0182ee..e0a8a5ea10 100644 --- a/src/i18n/messages/te.json +++ b/src/i18n/messages/te.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "ఐకాన్ URL", "iconUrlHint": "ఐచ్ఛికం. ఈ ప్రొవైడర్ ఐకాన్‌గా చూపబడే చిత్రం URL.", "iconUrlInvalid": "చెల్లని ఐకాన్ URL. http(s):// లేదా data:image/*;base64 URL ఉపయోగించండి.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai వద్ద API కీని సృష్టించండి, ఆపై దానిని ఇక్కడ Bearer టోకెన్‌గా పేస్ట్ చేయండి. https://router.requesty.ai/v1 వద్ద లైవ్ /v1/models కేటలాగ్‌తో OpenAI-అనుకూల ఎండ్‌పాయింట్ ఉంది.", "runwayml": "Runway వీడియో జనరేషన్ టాస్క్-ఆధారితమైనది. OmniRoute అనేది టెక్స్ట్-టు-వీడియో లేదా ఇమేజ్-టు-వీడియో జాబ్‌లను సమర్పిస్తుంది, /v1/tasks/[id] ని పోల్ చేస్తుంది మరియు పూర్తయిన వీడియో అవుట్‌పుట్‌లను తిరిగి OpenAI-వంటి /v1/videos/generations ప్రతిస్పందనగా సాధారణీకరిస్తుంది.", "sambanova": "సైన్అప్ చేసినప్పుడు $5 ఉచిత క్రెడిట్‌లు (30 రోజుల చెల్లుబాటు), క్రెడిట్ కార్డ్ అవసరం లేదు", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "మోడల్ డిస్కవరీ AI_API_URL లో /v2/lm/scenarios/foundation-models/models ని ఉపయోగిస్తుంది. చాట్ అభ్యర్థనలు deploymentUrl/chat/completions ని ఉపయోగిస్తాయి మరియు AI-Resource-Group అవసరం.", "sarvam": "Sarvam AI OpenAI-తో అనుకూలంగా ఉంది /v1. OmniRoute /v1/modelsని ప్రోబ్ చేస్తుంది మరియు చాట్ ట్రాఫిక్‌ను /v1/chat/completionsకి రూట్ చేస్తుంది. మోడల్స్ ఇండిక్ భాషల కోసం ట్యూన్ చేయబడ్డాయి.", "scaleway": "కొత్త ఖాతాల కోసం 1M ఉచిత టోకెన్‌లు — EU/GDPR కంప్లైంట్ (పారిస్), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "మోడ్: {mode}", "compressionAdaptiveOff": "అడాప్టివ్ కాంటెక్స్ట్ బడ్జెట్: ఆఫ్ (లెగసీ ఆటో-ట్రిగ్గర్)", "compressionAdaptiveTarget": "అడాప్టివ్ ({mode}, పాలసీ: {policy}) — టార్గెట్ ≈ {target, number} టోకెన్‌లు ({contextLimit, number}-టోకెన్ విండో కోసం)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "ప్రొవైడర్ అవుట్‌పుట్‌ను తిరిగి రాయకుండా రెస్పాన్స్-షేపింగ్ సూచనలను ఇంజెక్ట్ చేయండి. స్వేచ్ఛగా కలపండి.", "mcpAccessibilityDescription": "MCP టూల్ అవుట్‌పుట్‌లను స్కోప్ చేస్తుంది (ప్రత్యేక స్టోర్).", "compressionStylesTileSummary": "{tokens, number} టోకెన్‌లు ఆదా చేయబడ్డాయి · {runs, plural, one {# రన్ స్టైల్ చేయబడింది} other {# రన్‌లు స్టైల్ చేయబడ్డాయి}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "ఆరోగ్యం", "cliproxyapiPort": "పోర్ట్", "qdrantHost": "హోస్ట్", - "qdrantCollection": "సేకరణ" + "qdrantCollection": "సేకరణ", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/th.json b/src/i18n/messages/th.json index 3bd9fcbad1..cd0ce32da4 100644 --- a/src/i18n/messages/th.json +++ b/src/i18n/messages/th.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL ไอคอน", "iconUrlHint": "ไม่บังคับ URL รูปภาพที่จะแสดงเป็นไอคอนของผู้ให้บริการรายนี้", "iconUrlInvalid": "URL ไอคอนไม่ถูกต้อง ใช้ http(s):// หรือ data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-ผลิตภัณฑ์", "openaiPrefixPlaceholder": "oc-ผลิตภัณฑ์", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "สร้างคีย์ API ที่ https://app.requesty.ai จากนั้นวางที่นี่เป็นโทเค็น Bearer ปลายทางที่เข้ากันได้กับ OpenAI อยู่ที่ https://router.requesty.ai/v1 พร้อมแคตตาล็อก /v1/models แบบสด", "runwayml": "การสร้างวิดีโอของ Runway เป็นแบบอิงตามงาน OmniRoute จะส่งงาน text-to-video หรือ image-to-video ดึงข้อมูลสถานะ /v1/tasks/[id] เป็นระยะ และปรับเอาต์พุตวิดีโอที่เสร็จสมบูรณ์ให้อยู่ในรูปแบบการตอบกลับ /v1/videos/generations ที่คล้ายกับ OpenAI", "sambanova": "เครดิตฟรี $5 เมื่อลงทะเบียน (มีอายุ 30 วัน) ไม่ต้องใช้บัตรเครดิต", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "การค้นหาโมเดลใช้ /v2/lm/scenarios/foundation-models/models บน AI_API_URL คำขอแชทใช้ deploymentUrl/chat/completions และต้องระบุ AI-Resource-Group", "sarvam": "Sarvam AI เข้ากันได้กับ OpenAI ที่ /v1. OmniRoute ตรวจสอบ /v1/models และจัดเส้นทางการสนทนาไปยัง /v1/chat/completions. โมเดลได้รับการปรับแต่งสำหรับภาษาอินดิก.", "scaleway": "โทเค็นฟรี 1M สำหรับบัญชีใหม่ — สอดคล้องตาม EU/GDPR (ปารีส), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "โหมด: {mode}", "compressionAdaptiveOff": "งบประมาณบริบทแบบปรับตัว: ปิด (การทริกเกอร์อัตโนมัติแบบเก่า)", "compressionAdaptiveTarget": "แบบปรับตัว ({mode}, นโยบาย: {policy}) — เป้าหมาย ≈ {target, number} โทเค็น (สำหรับหน้าต่าง {contextLimit, number} โทเค็น)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "แทรกคำสั่งกำหนดรูปแบบการตอบกลับโดยไม่ต้องเขียนผลลัพธ์ของผู้ให้บริการใหม่ สามารถผสมผสานได้อย่างอิสระ", "mcpAccessibilityDescription": "กำหนดขอบเขตผลลัพธ์เครื่องมือ MCP (แยกพื้นที่จัดเก็บ)", "compressionStylesTileSummary": "{tokens, number} โทเค็นที่ประหยัดได้ · {runs, plural, one {จัดรูปแบบแล้ว # ครั้ง} other {จัดรูปแบบแล้ว # ครั้ง}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "สุขภาพ", "cliproxyapiPort": "พอร์ต", "qdrantHost": "โฮสต์", - "qdrantCollection": "การรวบรวม" + "qdrantCollection": "การรวบรวม", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/tr.json b/src/i18n/messages/tr.json index 3bb8e99971..2545c5632f 100644 --- a/src/i18n/messages/tr.json +++ b/src/i18n/messages/tr.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "Simge URL'si", "iconUrlHint": "İsteğe bağlı. Bu sağlayıcının simgesi olarak gösterilen görsel URL'si.", "iconUrlInvalid": "Geçersiz simge URL'si. http(s):// veya data:image/*;base64 URL'si kullanın.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai adresinde bir API anahtarı oluşturun, ardından buraya Bearer token olarak yapıştırın. OpenAI uyumlu uç nokta, canlı bir /v1/models kataloğu ile birlikte https://router.requesty.ai/v1 adresindedir.", "runwayml": "Runway video üretimi görev tabanlıdır. OmniRoute, metinden videoya veya görselden videoya işleri gönderir, /v1/tasks/[id] uç noktasını sorgular ve tamamlanan video çıktılarını OpenAI benzeri /v1/videos/generations yanıtına normalleştirir.", "sambanova": "Kayıt olunduğunda $5 ücretsiz kredi (30 gün geçerli), kredi kartı gerekmez", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Model keşfi, AI_API_URL üzerindeki /v2/lm/scenarios/foundation-models/models yolunu kullanır. Sohbet istekleri deploymentUrl/chat/completions yolunu kullanır ve AI-Resource-Group gerektirir.", "sarvam": "Sarvam AI, OpenAI ile uyumludur ve /v1 üzerinde çalışır. OmniRoute, /v1/models'ı sorgular ve sohbet trafiğini /v1/chat/completions'a yönlendirir. Modeller, Hint dilleri için ayarlanmıştır.", "scaleway": "Yeni hesaplar için 1M ücretsiz token — AB/GDPR uyumlu (Paris), Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "mod: {mode}", "compressionAdaptiveOff": "Uyarlanabilir bağlam bütçesi: kapalı (eski otomatik tetikleyici)", "compressionAdaptiveTarget": "Uyarlanabilir ({mode}, politika: {policy}) — hedef ≈ {target, number} token ({contextLimit, number} tokenlık bir pencere için)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Sağlayıcı çıktısını yeniden yazmadan yanıt şekillendirme talimatları ekleyin. Serbestçe birleştirin.", "mcpAccessibilityDescription": "MCP araç çıktılarını kapsama alır (ayrı depo).", "compressionStylesTileSummary": "{tokens, number} token tasarruf edildi · {runs, plural, one {# çalıştırma stillendirildi} other {# çalıştırma stillendirildi}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Sağlık", "cliproxyapiPort": "Port", "qdrantHost": "Ana Bilgisayar", - "qdrantCollection": "Koleksiyon" + "qdrantCollection": "Koleksiyon", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/uk-UA.json b/src/i18n/messages/uk-UA.json index 2a92ff8d1c..51aa242a70 100644 --- a/src/i18n/messages/uk-UA.json +++ b/src/i18n/messages/uk-UA.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "URL-адреса іконки", "iconUrlHint": "Необов'язково. URL-адреса зображення, що відображається як іконка цього провайдера.", "iconUrlInvalid": "Недійсне URL-адреса значка. Використовуйте http(s):// або data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "Створіть API-ключ на https://app.requesty.ai, а потім вставте його сюди як Bearer-токен. OpenAI-сумісна кінцева точка на https://router.requesty.ai/v1, з живим каталогом /v1/models.", "runwayml": "Генерація відео в Runway базується на завданнях. OmniRoute надсилає завдання text-to-video або image-to-video, опитує /v1/tasks/[id] та нормалізує готові відеофайли назад у відповідь типу OpenAI /v1/videos/generations.", "sambanova": "$5 безкоштовних кредитів при реєстрації (дійсні 30 днів), кредитна картка не потрібна", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Виявлення моделей використовує /v2/lm/scenarios/foundation-models/models на AI_API_URL. Запити чату використовують deploymentUrl/chat/completions та потребують AI-Resource-Group.", "sarvam": "Sarvam AI сумісний з OpenAI на /v1. OmniRoute перевіряє /v1/models і маршрутизує чат-трафік на /v1/chat/completions. Моделі налаштовані для індійських мов.", "scaleway": "1 млн безкоштовних токенів для нових акаунтів — сумісно з EU/GDPR (Париж), Qwen3 235B та Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "режим: {mode}", "compressionAdaptiveOff": "Адаптивний бюджет контексту: вимкнено (застарілий автотригер)", "compressionAdaptiveTarget": "Адаптивний ({mode}, політика: {policy}) — ціль ≈ {target, number} токенів (для вікна в {contextLimit, number} токенів)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "Впроваджуйте інструкції формування відповіді без перезапису виводу провайдера. Вільно комбінуйте.", "mcpAccessibilityDescription": "Обмежує область виводу інструментів MCP (окреме сховище).", "compressionStylesTileSummary": "{tokens, number} токенів збережено · {runs, plural, one {# стилізований запуск} few {# стилізовані запуски} many {# стилізованих запусків} other {# стилізованих запусків}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "Здоров'я", "cliproxyapiPort": "Порт", "qdrantHost": "Хост", - "qdrantCollection": "Колекція" + "qdrantCollection": "Колекція", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "Двигун RTK", diff --git a/src/i18n/messages/ur.json b/src/i18n/messages/ur.json index d4bb63f192..0dcf4edd4b 100644 --- a/src/i18n/messages/ur.json +++ b/src/i18n/messages/ur.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "آئیکن URL", "iconUrlHint": "اختیاری۔ تصویر کا URL جو اس فراہم کنندہ کے آئیکن کے طور پر دکھایا گیا ہے۔", "iconUrlInvalid": "غلط آئیکن یو آر ایل۔ http(s):// یا data:image/*;base64 یو آر ایل استعمال کریں۔", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "https://app.requesty.ai پر ایک API key بنائیں، پھر اسے یہاں Bearer ٹوکن کے طور پر پیسٹ کریں۔ OpenAI سے ہم آہنگ اینڈ پوائنٹ https://router.requesty.ai/v1 پر ہے، جس میں ایک لائیو /v1/models کیٹلاگ موجود ہے۔", "runwayml": "Runway ویڈیو جنریشن ٹاسک پر مبنی ہے۔ OmniRoute، text-to-video یا image-to-video جابز جمع کراتا ہے، /v1/tasks/[id] کو پول کرتا ہے، اور مکمل شدہ ویڈیو آؤٹ پٹس کو واپس OpenAI جیسے /v1/videos/generations ریسپانس میں نارملائز کرتا ہے۔", "sambanova": "سائن اپ کرنے پر $5 مفت کریڈٹس (30 دن کی میعاد)، کسی کریڈٹ کارڈ کی ضرورت نہیں ہے", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "ماڈل کی دریافت AI_API_URL پر /v2/lm/scenarios/foundation-models/models کا استعمال کرتی ہے۔ چیٹ کی درخواستیں deploymentUrl/chat/completions کا استعمال کرتی ہیں اور ان کے لیے AI-Resource-Group درکار ہوتا ہے۔", "sarvam": "Sarvam AI OpenAI کے ساتھ ہم آہنگ ہے /v1 پر۔ OmniRoute /v1/models کی جانچ کرتا ہے اور چیٹ ٹریفک کو /v1/chat/completions پر بھیجتا ہے۔ ماڈلز کو انڈک زبانوں کے لیے ترتیب دیا گیا ہے۔", "scaleway": "نئے اکاؤنٹس کے لیے 1M مفت ٹوکنز — EU/GDPR کے مطابق (پیرس)، Qwen3 235B اور Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "موڈ: {mode}", "compressionAdaptiveOff": "اڈیپٹیو کانٹیکسٹ بجٹ: بند (لیگیسی آٹو ٹریگر)", "compressionAdaptiveTarget": "اڈیپٹیو ({mode}, پالیسی: {policy}) — ہدف ≈ {target, number} ٹوکنز ({contextLimit, number}-ٹوکن ونڈو کے لیے)", + "compressionAdaptiveMode": "__MISSING__:Adaptive context budget", + "compressionAdaptiveModeOff": "__MISSING__:Off (legacy auto-trigger)", + "compressionAdaptiveModeFloor": "__MISSING__:Floor (always guarantee fit)", + "compressionAdaptiveModeReplace": "__MISSING__:Replace auto-trigger", + "compressionAdaptivePolicy": "__MISSING__:Budget policy", + "compressionAdaptivePolicyReserve": "__MISSING__:Reserve output", + "compressionAdaptivePolicyPercentage": "__MISSING__:Percentage of window", + "compressionAdaptivePolicyAbsolute": "__MISSING__:Absolute token budget", "compressionOutputStylesDescription": "پرووائیڈر آؤٹ پٹ کو دوبارہ لکھے بغیر رسپانس شیپنگ ہدایات شامل کریں۔ آزادانہ طور پر یکجا کریں۔", "mcpAccessibilityDescription": "MCP ٹول آؤٹ پٹس کو اسکوپ کرتا ہے (علیحدہ اسٹور)۔", "compressionStylesTileSummary": "{tokens, number} ٹوکنز محفوظ کیے گئے · {runs, plural, one {# رن اسٹائل کیا گیا} other {# رنز اسٹائل کیے گئے}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "صحت", "cliproxyapiPort": "پورٹ", "qdrantHost": "میزبان", - "qdrantCollection": "اجتماع" + "qdrantCollection": "اجتماع", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index 6ef8d58ce2..818c3d14cb 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -5068,6 +5068,9 @@ "importFromFileImporting": "Đang nhập…", "importFromFileImport": "Nhập {count} nhà cung cấp", "importFromFileResult": "Đã nhập {success} nhà cung cấp ({failed} không thành công)", + "importFromFileDownloadTemplate": "Tải mẫu CSV", + "importFromFileMoreErrors": "+{count} nữa", + "importFromFileSchemaHint": "Các cột CSV theo vị trí: provider, name, apiKey, baseUrl (tùy chọn), priority (tùy chọn). Cột provider phải là id nhà cung cấp được quản lý hiện có hoặc id nút tương thích OpenAI/Anthropic đã đăng ký — quá trình nhập này không tạo nút endpoint mới.", "adaptaTutorial": { "title": "How to connect Adapta Web", "introPrefix": "Adapta authenticates through Clerk. The token", @@ -5249,6 +5252,10 @@ "iconUrlLabel": "URL biểu tượng", "iconUrlHint": "Tùy chọn. URL hình ảnh được hiển thị làm biểu tượng của nhà cung cấp này.", "iconUrlInvalid": "URL biểu tượng không hợp lệ. Sử dụng http(s):// hoặc data:image/*;base64 URL.", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6240,6 +6247,7 @@ "requesty": "Tạo khóa API tại https://app.requesty.ai, rồi dán dưới dạng token Bearer. Endpoint tương thích OpenAI tại https://router.requesty.ai/v1, kèm danh mục /v1/models trực tiếp.", "runwayml": "Tạo video Runway hoạt động theo tác vụ. OmniRoute gửi tác vụ chuyển văn bản hoặc hình ảnh thành video, thăm dò /v1/tasks/[id], rồi chuẩn hóa đầu ra hoàn tất về phản hồi /v1/videos/generations kiểu OpenAI.", "sambanova": "5 USD tín dụng miễn phí khi đăng ký (có hiệu lực 30 ngày), không cần thẻ tín dụng", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "Khám phá mô hình dùng /v2/lm/scenarios/foundation-models/models trên AI_API_URL. Yêu cầu trò chuyện dùng deploymentUrl/chat/completions và yêu cầu AI-Resource-Group.", "sarvam": "Sarvam AI tương thích OpenAI trên /v1. OmniRoute thăm dò /v1/models và định tuyến trò chuyện tới /v1/chat/completions. Các mô hình được tinh chỉnh cho ngôn ngữ Ấn Độ.", "scaleway": "1 triệu token miễn phí cho tài khoản mới — tuân thủ EU/GDPR (Paris), Qwen3 235B và Llama 70B", @@ -7813,6 +7821,14 @@ "compressionDerivedMode": "chế độ: {mode}", "compressionAdaptiveOff": "Ngân sách ngữ cảnh thích ứng: đã tắt (dùng ngưỡng tự động kích hoạt cũ)", "compressionAdaptiveTarget": "Thích ứng ({mode}, chính sách: {policy}) — mục tiêu ≈ {target, number} token (với cửa sổ {contextLimit, number} token)", + "compressionAdaptiveMode": "Ngân sách ngữ cảnh thích ứng", + "compressionAdaptiveModeOff": "Tắt (ngưỡng tự động kích hoạt cũ)", + "compressionAdaptiveModeFloor": "Sàn (luôn đảm bảo vừa cửa sổ)", + "compressionAdaptiveModeReplace": "Thay thế tự động kích hoạt", + "compressionAdaptivePolicy": "Chính sách ngân sách", + "compressionAdaptivePolicyReserve": "Dành chỗ cho đầu ra", + "compressionAdaptivePolicyPercentage": "Phần trăm cửa sổ", + "compressionAdaptivePolicyAbsolute": "Ngân sách token tuyệt đối", "compressionOutputStylesDescription": "Chèn hướng dẫn định hình phản hồi mà không viết lại đầu ra của nhà cung cấp. Có thể kết hợp tự do.", "mcpAccessibilityDescription": "Giới hạn phạm vi đầu ra của công cụ MCP (được lưu riêng).", "compressionStylesTileSummary": "Đã tiết kiệm {tokens, number} token · {runs, number} lượt áp dụng kiểu", @@ -8341,7 +8357,15 @@ "cliproxyapiHealth": "Sức Khỏe", "cliproxyapiPort": "Cổng", "qdrantHost": "Máy chủ", - "qdrantCollection": "Bộ Sưu Tập" + "qdrantCollection": "Bộ Sưu Tập", + "headroomProxyTitle": "Proxy Headroom", + "headroomProxyDesc": "URL của proxy tiết kiệm token Headroom (tùy chọn). Để trống thì dùng HEADROOM_URL hoặc http://localhost:8787.", + "headroomProxyUrl": "URL Headroom", + "headroomProxyUrlHint": "URL loopback có thể khởi chạy từ trang này. URL bên ngoài chỉ được kiểm tra.", + "headroomProxySave": "Lưu", + "headroomProxyStart": "Bắt đầu", + "headroomProxyStop": "Dừng", + "headroomProxyExternalHint": "URL này không phải loopback, nên OmniRoute sẽ không khởi chạy CLI cục bộ." }, "contextRtk": { "title": "RTK Engine", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 0cfbcb850e..b73ba46a0d 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "图标 URL", "iconUrlHint": "可选。显示为此服务商图标的图片 URL。", "iconUrlInvalid": "无效的图标 URL。请使用 http(s):// 或 data:image/*;base64 URL。", + "dailyQuotaResetTimezoneLabel": "每日额度重置时区", + "dailyQuotaResetTimezoneHint": "可选。上游不返回 X-RateLimit-Reset 时使用的 IANA 时区。留空则只做短冷却。", + "dailyQuotaResetHourLabel": "每日额度重置小时", + "dailyQuotaResetHourHint": "上述时区的本地小时,0-23。留空表示不配置节点级时钟。", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "在 https://app.requesty.ai 创建 API 密钥,然后将其作为 Bearer 令牌粘贴在此处。兼容 OpenAI 的端点位于 https://router.requesty.ai/v1,并提供实时的 /v1/models 目录。", "runwayml": "Runway 视频生成基于任务。OmniRoute 提交文生视频或图生视频作业,轮询 /v1/tasks/[id],并将完成的视频输出规范化为类似 OpenAI 的 /v1/videos/generations 响应。", "sambanova": "注册即送 $5 免费额度(30 天有效期),无需信用卡", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "模型发现使用 AI_API_URL 上的 /v2/lm/scenarios/foundation-models/models。Chat 请求使用 deploymentUrl/chat/completions 并需要 AI-Resource-Group。", "sarvam": "Sarvam AI 在 /v1 上兼容 OpenAI。OmniRoute 探测 /v1/models 并将聊天流量路由到 /v1/chat/completions。模型针对印度语言进行了优化。", "scaleway": "新账户可获 1M 免费 Token — 符合 EU/GDPR 规范(巴黎),Qwen3 235B & Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "模式:{mode}", "compressionAdaptiveOff": "自适应上下文预算:关闭(旧版自动触发)", "compressionAdaptiveTarget": "自适应 ({mode}, 策略: {policy}) — 目标 ≈ {target, number} 个 token (针对 {contextLimit, number} 个 token 的窗口)", + "compressionAdaptiveMode": "自适应上下文预算", + "compressionAdaptiveModeOff": "关闭(旧版自动触发)", + "compressionAdaptiveModeFloor": "下限(始终保证适配)", + "compressionAdaptiveModeReplace": "替换自动触发", + "compressionAdaptivePolicy": "预算策略", + "compressionAdaptivePolicyReserve": "预留输出", + "compressionAdaptivePolicyPercentage": "窗口百分比", + "compressionAdaptivePolicyAbsolute": "绝对 token 预算", "compressionOutputStylesDescription": "注入响应塑造指令,而无需重写服务商输出。自由组合。", "mcpAccessibilityDescription": "限制 MCP 工具输出的范围 (独立存储)。", "compressionStylesTileSummary": "{tokens, number} 个 token 已节省 · {runs, plural, one {# 次运行已应用样式} other {# 次运行已应用样式}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "健康", "cliproxyapiPort": "端口", "qdrantHost": "主机", - "qdrantCollection": "集合" + "qdrantCollection": "集合", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "命令输出过滤引擎", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index c9f804f100..90e89ba3ed 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5248,6 +5248,10 @@ "iconUrlLabel": "圖示網址", "iconUrlHint": "選用。顯示為此提供者圖示的圖片網址。", "iconUrlInvalid": "無效的圖示網址。請使用 http(s):// 或 data:image/*;base64 URL。", + "dailyQuotaResetTimezoneLabel": "Daily quota reset timezone", + "dailyQuotaResetTimezoneHint": "Optional IANA timezone used when the upstream omits X-RateLimit-Reset. Leave empty to keep a short cooldown.", + "dailyQuotaResetHourLabel": "Daily quota reset hour", + "dailyQuotaResetHourHint": "Local hour 0-23 in the timezone above. Empty means no node-level clock.", "anthropicPrefixPlaceholder": "ac-prod", "openaiPrefixPlaceholder": "oc-prod", "anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1", @@ -6239,6 +6243,7 @@ "requesty": "在 https://app.requesty.ai 建立 API 金鑰,然後以 Bearer token 形式貼上。OpenAI 相容端點為 https://router.requesty.ai/v1,附即時 /v1/models 目錄。", "runwayml": "Runway 影片生成為任務導向。OmniRoute 提交文字轉影片或圖片轉影片作業,輪詢 /v1/tasks/[id],並將完成的影片輸出正規化為類似 OpenAI 的 /v1/videos/generations 回應。", "sambanova": "註冊即贈 $5 美元免費額度(30 天有效期),無需信用卡", + "seekai": "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", "sap": "模型探索使用 AI_API_URL 上的 /v2/lm/scenarios/foundation-models/models。聊天請求使用 deploymentUrl/chat/completions,並需要 AI-Resource-Group。", "sarvam": "使用API Key連接Sarvam AI。", "scaleway": "新帳戶贈送 100 萬免費 tokens — 符合歐盟/GDPR 規範(巴黎),Qwen3 235B 和 Llama 70B", @@ -7812,6 +7817,14 @@ "compressionDerivedMode": "模式:{mode}", "compressionAdaptiveOff": "自適應上下文預算:關閉(傳統自動觸發)", "compressionAdaptiveTarget": "自適應({mode},策略:{policy})— 目標約 {target, number} tokens(針對 {contextLimit, number} token 的視窗)", + "compressionAdaptiveMode": "自適應上下文預算", + "compressionAdaptiveModeOff": "關閉(傳統自動觸發)", + "compressionAdaptiveModeFloor": "下限(始終保證適配)", + "compressionAdaptiveModeReplace": "替換自動觸發", + "compressionAdaptivePolicy": "預算策略", + "compressionAdaptivePolicyReserve": "預留輸出", + "compressionAdaptivePolicyPercentage": "視窗百分比", + "compressionAdaptivePolicyAbsolute": "絕對 token 預算", "compressionOutputStylesDescription": "注入回應塑形指令,無需改寫提供者輸出。可自由組合。", "mcpAccessibilityDescription": "限定 MCP 工具輸出範圍(獨立儲存區)。", "compressionStylesTileSummary": "{tokens, number} tokens saved · {runs, plural, one {# run styled} other {# runs styled}}", @@ -8340,7 +8353,15 @@ "cliproxyapiHealth": "健康", "cliproxyapiPort": "埠", "qdrantHost": "主機", - "qdrantCollection": "集合" + "qdrantCollection": "集合", + "headroomProxyTitle": "__MISSING__:Headroom proxy", + "headroomProxyDesc": "__MISSING__:URL of the optional Headroom token-saver proxy. Empty uses HEADROOM_URL or http://localhost:8787.", + "headroomProxyUrl": "__MISSING__:Headroom URL", + "headroomProxyUrlHint": "__MISSING__:Loopback URLs can be started from this page. External URLs are probed only.", + "headroomProxySave": "__MISSING__:Save", + "headroomProxyStart": "__MISSING__:Start", + "headroomProxyStop": "__MISSING__:Stop", + "headroomProxyExternalHint": "__MISSING__:This URL is not loopback, so OmniRoute will not spawn the local CLI." }, "contextRtk": { "title": "RTK 引擎", diff --git a/src/instrumentation-node.ts b/src/instrumentation-node.ts index 069b8740b8..e3ae959118 100755 --- a/src/instrumentation-node.ts +++ b/src/instrumentation-node.ts @@ -282,6 +282,7 @@ export async function registerQuotaFetchers(): Promise { { registerQwenTokenPlanQuotaFetcher }, { registerCrofUsageFetcher }, { registerDeepseekQuotaFetcher }, + { registerMoonshotQuotaFetcher, registerMoonshotFetchersForNodes }, { registerOpenrouterQuotaFetcher }, { registerOpencodeQuotaFetcher }, { registerGrokWebQuotaFetcher }, @@ -292,6 +293,7 @@ export async function registerQuotaFetchers(): Promise { import("@omniroute/open-sse/services/qwenTokenPlanQuotaFetcher"), import("@omniroute/open-sse/services/crofUsageFetcher"), import("@omniroute/open-sse/services/deepseekQuotaFetcher"), + import("@omniroute/open-sse/services/moonshotQuotaFetcher"), import("@omniroute/open-sse/services/openrouterQuotaFetcher"), import("@omniroute/open-sse/services/opencodeQuotaFetcher"), import("@omniroute/open-sse/services/grokQuotaFetcher"), @@ -303,6 +305,20 @@ export async function registerQuotaFetchers(): Promise { registerQwenTokenPlanQuotaFetcher(); registerCrofUsageFetcher(); registerDeepseekQuotaFetcher(); + registerMoonshotQuotaFetcher(); + try { + const { getProviderNodes } = await import("@/lib/db/providers"); + const nodes = await getProviderNodes(); + registerMoonshotFetchersForNodes( + (Array.isArray(nodes) ? nodes : []).map((node) => ({ + id: typeof node.id === "string" ? node.id : null, + prefix: typeof node.prefix === "string" ? node.prefix : null, + baseUrl: typeof node.baseUrl === "string" ? node.baseUrl : null, + })), + ); + } catch (error) { + console.warn("[STARTUP] Moonshot custom-node fetcher scan skipped:", error); + } registerOpenrouterQuotaFetcher(); registerOpencodeQuotaFetcher(); registerGrokWebQuotaFetcher(); diff --git a/src/lib/db/agenticConversations.ts b/src/lib/db/agenticConversations.ts index 646fce57a7..0bdb9ff4e2 100644 --- a/src/lib/db/agenticConversations.ts +++ b/src/lib/db/agenticConversations.ts @@ -357,6 +357,12 @@ export interface MultiTurnConversationRow extends AgenticConversationRow { lastModel: string | null; lastProvider: string | null; lastStatus: number | null; + // Exposed so the API layer can check whether the latest turn genuinely + // used HTTP continuation (see isGenuineContinuationTurn in + // responsesContinuationStore.ts) without a second query — this row's own + // artifact/tenant already identify it, no separate lookup needed. + lastArtifactRelPath: string | null; + lastApiKeyId: string | null; } /** @@ -400,16 +406,7 @@ export function listMultiTurnConversations( const rows = db .prepare( - `SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model, - latest.provider as last_provider, latest.status as last_status - FROM agentic_conversations ac - LEFT JOIN ( - SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status - FROM call_logs cl1 - WHERE cl1.timestamp = ( - SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag - ) - ) latest ON latest.session_tag = ac.id + `${MULTI_TURN_CONVERSATION_SELECT} WHERE (SELECT COUNT(*) FROM conversation_turn_nodes n WHERE n.conversation_id = ac.id) >= 2 ORDER BY ac.last_seen_at DESC LIMIT ? OFFSET ?` @@ -418,15 +415,52 @@ export function listMultiTurnConversations( return { total: Number(total ?? 0), - rows: rows.map((r) => { - const rec = asRecord(r); - return { - ...toRow(rec), - lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null, - lastModel: typeof rec.last_model === "string" ? rec.last_model : null, - lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null, - lastStatus: typeof rec.last_status === "number" ? rec.last_status : null, - }; - }), + rows: rows.map(toMultiTurnConversationRow), }; } + +function toMultiTurnConversationRow(value: unknown): MultiTurnConversationRow { + const rec = asRecord(value); + return { + ...toRow(rec), + lastCallLogId: typeof rec.last_call_log_id === "string" ? rec.last_call_log_id : null, + lastModel: typeof rec.last_model === "string" ? rec.last_model : null, + lastProvider: typeof rec.last_provider === "string" ? rec.last_provider : null, + lastStatus: typeof rec.last_status === "number" ? rec.last_status : null, + lastArtifactRelPath: + typeof rec.last_artifact_relpath === "string" ? rec.last_artifact_relpath : null, + lastApiKeyId: typeof rec.last_api_key_id === "string" ? rec.last_api_key_id : null, + }; +} + +const MULTI_TURN_CONVERSATION_SELECT = ` + SELECT ac.*, latest.id as last_call_log_id, latest.model as last_model, + latest.provider as last_provider, latest.status as last_status, + latest.artifact_relpath as last_artifact_relpath, + latest.api_key_id as last_api_key_id + FROM agentic_conversations ac + LEFT JOIN ( + SELECT cl1.id, cl1.session_tag, cl1.model, cl1.provider, cl1.status, + cl1.artifact_relpath, cl1.api_key_id + FROM call_logs cl1 + WHERE cl1.timestamp = ( + SELECT MAX(cl2.timestamp) FROM call_logs cl2 WHERE cl2.session_tag = cl1.session_tag + ) + ) latest ON latest.session_tag = ac.id +`; + +/** + * Single-conversation equivalent of listMultiTurnConversations, for the + * dashboard's conversation modal: while it's open, polling this one row on + * the refresh interval (instead of the whole up-to-200-row list just to + * pluck one row back out of it) is what actually needs to stay live — + * lastCallLogId/lastStatus for "Goto latest request" and isActive detection. + * Unlike the list, this intentionally has no turn-count floor: a + * specifically-requested conversation should resolve even if it hasn't (yet) + * reached 2 turn nodes. + */ +export function getMultiTurnConversationById(id: string): MultiTurnConversationRow | null { + const db = getDbInstance(); + const row = db.prepare(`${MULTI_TURN_CONVERSATION_SELECT} WHERE ac.id = ?`).get(id); + return row ? toMultiTurnConversationRow(row) : null; +} diff --git a/src/lib/db/migrationRunner.ts b/src/lib/db/migrationRunner.ts index 498be0f9bc..f53cd58070 100644 --- a/src/lib/db/migrationRunner.ts +++ b/src/lib/db/migrationRunner.ts @@ -516,6 +516,11 @@ function isSchemaAlreadyApplied( db.prepare("SELECT 1 FROM provider_connections WHERE provider = 'freepik' LIMIT 1").get() == null ); + case "172": + return ( + hasColumn(db, "provider_nodes", "daily_quota_reset_timezone") && + hasColumn(db, "provider_nodes", "daily_quota_reset_hour") + ); default: return false; } diff --git a/src/lib/db/migrations/172_provider_node_daily_quota_reset.sql b/src/lib/db/migrations/172_provider_node_daily_quota_reset.sql new file mode 100644 index 0000000000..969df2e0fb --- /dev/null +++ b/src/lib/db/migrations/172_provider_node_daily_quota_reset.sql @@ -0,0 +1,6 @@ +-- 172: per-node daily quota reset clock (IANA timezone + local hour). +-- Used by TPD cooldown when upstream omits X-RateLimit-Reset. +-- Both columns nullable: empty = operator has not configured a clock. + +ALTER TABLE provider_nodes ADD COLUMN daily_quota_reset_timezone TEXT; +ALTER TABLE provider_nodes ADD COLUMN daily_quota_reset_hour INTEGER; diff --git a/src/lib/db/models/activeSyncedCatalog.ts b/src/lib/db/models/activeSyncedCatalog.ts index 981175219f..18ade8260b 100644 --- a/src/lib/db/models/activeSyncedCatalog.ts +++ b/src/lib/db/models/activeSyncedCatalog.ts @@ -1,5 +1,6 @@ import { providerUsesAuthoritativeLiveCatalog } from "@omniroute/open-sse/config/providerRegistry"; import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; +import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; import { getSyncedAvailableModels, getSyncedAvailableModelsByConnection, @@ -76,6 +77,19 @@ function collectModelsForConnections( return Array.from(models.values()); } +function enrichCursorCatalog( + providerId: string, + models: SyncedAvailableModel[] +): SyncedAvailableModel[] { + // An empty sync means discovery has not completed (or failed). Do not let the + // synthetic Cursor auto-router rows turn that empty state into an authoritative + // catalog, otherwise every built-in model is incorrectly marked unavailable. + if (models.length === 0) return models; + return providerId === "cursor" || providerId === "cursor-api" + ? ensureCursorAutoCatalogEntry(models) + : models; +} + /** * Return the unioned synced catalog belonging only to active connections. * @@ -105,7 +119,10 @@ export async function getActiveSyncedCatalog(providerId: string): Promise connection !== null) .map((connection) => connection.id); - const models = collectModelsForConnections(modelsByConnection, activeConnectionIds); + const models = enrichCursorCatalog( + storedProviderId, + collectModelsForConnections(modelsByConnection, activeConnectionIds) + ); if (models.length > 0) { return { authoritative: providerUsesAuthoritativeLiveCatalog(providerId), @@ -125,7 +142,13 @@ export async function getActiveSyncedCatalog(providerId: string): Promise { const modelsByConnection = await getSyncedAvailableModelsByConnection(providerId); - const models = collectModelsForConnections(modelsByConnection, connectionIds); + const models = enrichCursorCatalog( + providerId, + collectModelsForConnections(modelsByConnection, connectionIds) + ); if (models.length > 0) { result[providerId] = models; diff --git a/src/lib/db/providers/nodes.ts b/src/lib/db/providers/nodes.ts index 7c9a251a78..3bc975db92 100644 --- a/src/lib/db/providers/nodes.ts +++ b/src/lib/db/providers/nodes.ts @@ -9,6 +9,25 @@ import { backupDbFile } from "../backup"; import { invalidateDbCache } from "../readCache"; import { toRecord, type JsonRecord } from "./columns"; +function normalizeDailyQuotaResetHour(value: unknown): number | null { + return value === 0 || value ? Number(value) : null; +} + +function withParsedCustomHeaders(node: JsonRecord, storedJson: string | null): JsonRecord { + const result: JsonRecord = { ...node }; + if (storedJson) { + try { + result.customHeaders = JSON.parse(storedJson); + } catch { + result.customHeaders = null; + } + } else { + result.customHeaders = null; + } + delete result.customHeadersJson; + return result; +} + interface StatementLike { all: (...params: unknown[]) => TRow[]; get: (...params: unknown[]) => TRow | undefined; @@ -88,32 +107,23 @@ export async function createProviderNode(data: JsonRecord) { // Optional operator-supplied remote icon URL (#2166) — plain TEXT, no JSON parsing needed. iconUrl: data.iconUrl || null, customHeadersJson, + dailyQuotaResetTimezone: data.dailyQuotaResetTimezone || null, + dailyQuotaResetHour: normalizeDailyQuotaResetHour(data.dailyQuotaResetHour), createdAt: now, updatedAt: now, }; db.prepare( ` - INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, icon_url, custom_headers_json, created_at, updated_at) - VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @iconUrl, @customHeadersJson, @createdAt, @updatedAt) + INSERT INTO provider_nodes (id, type, name, prefix, api_type, base_url, chat_path, models_path, icon_url, custom_headers_json, daily_quota_reset_timezone, daily_quota_reset_hour, created_at, updated_at) + VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @chatPath, @modelsPath, @iconUrl, @customHeadersJson, @dailyQuotaResetTimezone, @dailyQuotaResetHour, @createdAt, @updatedAt) ` ).run(node); backupDbFile("pre-write"); invalidateDbCache("nodes"); - const result: JsonRecord = { ...node }; - if (customHeadersJson) { - try { - result.customHeaders = JSON.parse(customHeadersJson); - } catch { - result.customHeaders = null; - } - } else { - result.customHeaders = null; - } - delete result.customHeadersJson; - return result; + return withParsedCustomHeaders({ ...node }, customHeadersJson); } export async function updateProviderNode(id: string, data: JsonRecord) { @@ -144,7 +154,10 @@ export async function updateProviderNode(id: string, data: JsonRecord) { UPDATE provider_nodes SET type = @type, name = @name, prefix = @prefix, api_type = @apiType, base_url = @baseUrl, chat_path = @chatPath, models_path = @modelsPath, icon_url = @iconUrl, - custom_headers_json = @customHeadersJson, updated_at = @updatedAt + custom_headers_json = @customHeadersJson, + daily_quota_reset_timezone = @dailyQuotaResetTimezone, + daily_quota_reset_hour = @dailyQuotaResetHour, + updated_at = @updatedAt WHERE id = @id ` ).run({ @@ -160,25 +173,15 @@ export async function updateProviderNode(id: string, data: JsonRecord) { // stored custom icon when the caller submits an empty value. iconUrl: merged["iconUrl"] || null, customHeadersJson: merged["customHeadersJson"] || null, + dailyQuotaResetTimezone: merged["dailyQuotaResetTimezone"] || null, + dailyQuotaResetHour: normalizeDailyQuotaResetHour(merged["dailyQuotaResetHour"]), updatedAt: merged["updatedAt"], }); backupDbFile("pre-write"); invalidateDbCache("nodes"); - const result: JsonRecord = { ...merged }; - const storedJson = merged["customHeadersJson"] as string | null; - if (storedJson) { - try { - result.customHeaders = JSON.parse(storedJson); - } catch { - result.customHeaders = null; - } - } else { - result.customHeaders = null; - } - delete result.customHeadersJson; - return result; + return withParsedCustomHeaders(merged, (merged["customHeadersJson"] as string | null) ?? null); } export async function deleteProviderNode(id: string) { diff --git a/src/lib/db/responsesContinuationStore.ts b/src/lib/db/responsesContinuationStore.ts index ef7a6e3de3..9c55d8edff 100644 --- a/src/lib/db/responsesContinuationStore.ts +++ b/src/lib/db/responsesContinuationStore.ts @@ -107,5 +107,129 @@ export function resolvePreviousResponseState( if (!Array.isArray(input) || !Array.isArray(output)) return null; if (containsTruncatedArrayMarker(input) || containsTruncatedArrayMarker(output)) return null; + // Live incident (2026-09-02): a huge/reasoning-heavy response can blow past + // createStructuredSSECollector's own event-count cap mid-stream -- the + // stored clientResponse then carries `_truncated: true` and + // `summary.status: "in_progress"` (never reached "completed") with a + // genuinely EMPTY `summary.output`, not a bounded array with a + // containsTruncatedArrayMarker sentinel (that marker only covers an + // array capped mid-array, not a collector that stopped before ever + // populating output at all). An empty output array passed the checks + // above and got merged into the next turn's request as this response's + // entire contribution -- reconstructing to zero real messages, which the + // upstream provider then rejected outright ("Input required: specify + // prompt or messages"), breaking the conversation with no client-visible + // continuation path. A response the client received as real (successful, + // non-empty) always has at least one output item; failing closed here + // makes the caller ask the client to resend full history instead of + // silently reconstructing an empty one, exactly like a real + // previous_response_not_found from OpenAI itself. + if ((clientResponse as { _truncated?: unknown } | undefined)?._truncated === true) return null; + if (output.length === 0) return null; + return { input, output }; } + +/** + * Resolve the call-log id that produced `responseId`, for the dashboard's + * "continues from" link. Reuses the same `call_logs.response_id` index and + * `api_key_id` tenant scoping as `resolvePreviousResponseState` above -- a + * parent link must never point across API keys, even just to surface its id. + * Returns null on any lookup miss so the caller renders no link rather than + * a broken one. + */ +export function resolveCallLogIdByResponseId( + responseId: string, + apiKeyId: string | null | undefined +): string | null { + if (!responseId || !apiKeyId) return null; + + const db = getDbInstance(); + const row = db + .prepare( + `SELECT id FROM call_logs + WHERE response_id = ? AND api_key_id = ? + ORDER BY timestamp DESC LIMIT 1` + ) + .get(responseId, apiKeyId) as { id: string } | undefined; + + return row?.id ?? null; +} + +/** + * Extract `previous_response_id` from a call-log's own pipeline payload. + * Persisted artifacts key the client's own request `clientRawRequest`; the + * pending/in-flight in-memory shape keys the same thing `clientRequest` + * instead (RequestLoggerDetail.tsx's payloadSections list carries both keys + * for the same reason) -- check both so callers get the same answer + * regardless of which shape the payload came back as. + */ +export function extractPreviousResponseId( + pipelinePayloads: Record | null | undefined +): string | null { + if (!pipelinePayloads) return null; + for (const key of ["clientRawRequest", "clientRequest"]) { + const envelope = pipelinePayloads[key]; + const body = isPlainRecord(envelope) && "body" in envelope ? envelope.body : envelope; + if (isPlainRecord(body) && typeof body.previous_response_id === "string") { + return body.previous_response_id; + } + } + return null; +} + +// isGenuineContinuationTurn is a pure function of one call-log's own +// artifact, which is immutable once written (see callLogs.ts -- detailState +// only flips to "ready" after the artifact is fully persisted) -- the same +// artifactRelPath always answers the same way, forever. Without this cache, +// the dashboard's own default auto-refresh polls the whole conversation list +// on an interval the operator controls (down to 1s), so every tick re-reads +// and re-parses one artifact per visible row for an answer that can never +// change once computed. Keyed on artifactRelPath alone (1:1 with the owning +// call-log row, so apiKeyId never varies for a given key) with simple FIFO +// eviction -- correctness never depends on which entries survive, only on +// staying bounded. +const GENUINE_CONTINUATION_CACHE_MAX = 5000; +const genuineContinuationCache = new Map(); + +function cacheGenuineContinuation(key: string, value: boolean): boolean { + genuineContinuationCache.set(key, value); + if (genuineContinuationCache.size > GENUINE_CONTINUATION_CACHE_MAX) { + const oldest = genuineContinuationCache.keys().next().value; + if (oldest !== undefined) genuineContinuationCache.delete(oldest); + } + return value; +} + +/** + * Whether a call-log's own request genuinely continued a prior response + * server-side: it carried `previous_response_id` AND that id resolved to a + * real, same-tenant prior call-log row. Backs the /dashboard/conversations + * "genuine continuation" badge -- a conversation the client-side turn + * tracker counts as multi-turn (conversationTracker.ts's content-hash chain, + * independent of transport) is not necessarily one actually running on the + * `previous_response_id` wire optimization; this checks the transport fact, + * not the content-hash one. + */ +export function isGenuineContinuationTurn( + artifactRelPath: string | null | undefined, + apiKeyId: string | null | undefined +): boolean { + if (!artifactRelPath) return false; + const cached = genuineContinuationCache.get(artifactRelPath); + if (cached !== undefined) return cached; + + const { artifact, state } = readCallArtifact(artifactRelPath); + if (state !== "ready" || !artifact?.pipeline) { + return cacheGenuineContinuation(artifactRelPath, false); + } + const previousResponseId = extractPreviousResponseId( + artifact.pipeline as Record + ); + if (!previousResponseId) return cacheGenuineContinuation(artifactRelPath, false); + + return cacheGenuineContinuation( + artifactRelPath, + resolveCallLogIdByResponseId(previousResponseId, apiKeyId) !== null + ); +} diff --git a/src/lib/guardrails/videoBridgeSnapshotRedaction.ts b/src/lib/guardrails/videoBridgeSnapshotRedaction.ts new file mode 100644 index 0000000000..8324b4be90 --- /dev/null +++ b/src/lib/guardrails/videoBridgeSnapshotRedaction.ts @@ -0,0 +1,149 @@ +/** + * #12150 P2 surface 1 (the dominant transcript-retention leak): structured redaction of + * video transcript fields on the CLIENT-REQUEST SNAPSHOT that lands in the detailed-log + * artifact. + * + * `clientRawRequest.body` (src/sse/handlers/chat/clientRawRequest.ts::buildClientRawRequest) + * is a bounded clone of the client's ORIGINAL request, captured BEFORE the guardrail chain + * runs, and persisted verbatim by `reqLogger.logClientRawRequest` + * (open-sse/handlers/chatCore.ts). Because it predates the video-bridge guardrail's own + * description redaction (#12150 P1 — see `describeVideoPart`'s `descriptionRedacted` in + * videoBridgeHelpers.ts), it still carries the client's raw `transcript` / `audioTranscript` + * cue text on any video part. This module redacts THAT COPY ONLY: the body sent to the + * provider and the response returned to the client are never touched here. + * + * Deliberately a standalone, dependency-light module — NOT part of videoBridgeHelpers.ts, + * which pulls in the frame-extraction broker client, audio/video fusion, contact-sheet + * composition and `sharp` for real video processing. The chat request hot path statically + * imports whatever module owns the `logClientRawRequest` call site on every request + * (video or not), so keeping this redaction free of that dependency chain matters for cold + * start and blast radius. + * + * The field walk mirrors `extractVideoParts` (videoBridgeHelpers.ts): for each content part, + * the candidate objects are the part itself, its `video_url` sub-object, and its `source` + * sub-object (the same three checked there) — but this walk is deliberately WIDER: any of + * those objects carrying a `transcript`/`audioTranscript` key gets redacted regardless of + * the part's `type`/shape. Those two field names are video-cue-only in this codebase's + * request contract, so matching on field presence rather than a shape allowlist is strictly + * safer (fails closed on an unusual or future video shape instead of silently skipping it). + * Redaction is a structured field substitution, not a scan over rendered text, so it cannot + * be bypassed by adversarial cue content (see the discarded regex approach recorded in the + * #12150 design doc, `_tasks/superpowers/specs/2026-09-01-video-transcript-retention-design.md`). + */ + +// Kept as a local literal (not imported from videoBridgeHelpers.ts) for the reason in the +// file header above. Equality with the canonical `VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER` +// export is enforced by a drift test in +// tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts. +const REDACTION_PLACEHOLDER = "[redacted-video-transcript]"; + +const TRANSCRIPT_FIELD_NAMES = ["transcript", "audioTranscript"] as const; +const NESTED_SUBOBJECT_KEYS = ["video_url", "source"] as const; +const CONTAINER_KEYS = ["messages", "input"] as const; + +type UnknownRecord = Record; + +function isPlainRecord(value: unknown): value is UnknownRecord { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +/** + * Overwrites transcript field VALUES in place on `part` and its `video_url`/`source` + * sub-objects. Only ever called on a part that already lives inside the function's own + * `structuredClone`, never on caller-owned data. Keys are overwritten, never deleted, so + * downstream shape/observability (e.g. "this part had a transcript") is preserved. + */ +function redactTranscriptFieldsOnPart(part: unknown): void { + if (!isPlainRecord(part)) return; + const candidates: UnknownRecord[] = [part]; + for (const key of NESTED_SUBOBJECT_KEYS) { + const nested = part[key]; + if (isPlainRecord(nested)) candidates.push(nested); + } + for (const candidate of candidates) { + for (const field of TRANSCRIPT_FIELD_NAMES) { + if (candidate[field] !== undefined) { + candidate[field] = REDACTION_PLACEHOLDER; + } + } + } +} + +function redactContentArray(content: unknown): void { + if (!Array.isArray(content)) return; + for (const part of content) { + redactTranscriptFieldsOnPart(part); + } +} + +/** `messages` (Chat Completions) or `input` (Responses API) — either container shape. */ +function redactContainer(container: unknown): void { + if (!Array.isArray(container)) return; + for (const message of container) { + if (!isPlainRecord(message)) continue; + redactContentArray(message.content); + } +} + +/** + * Returns a NEW structure with every video transcript cue field value replaced by the + * redaction placeholder. Never mutates `body` — the caller (chatCore.ts) must keep passing + * the untouched original to translation/dispatch/response. A non-object `body`, or one with + * neither `messages` nor `input`, or with video parts that carry no transcript field, is + * returned as an equivalent (cloned) structure with nothing to change. + */ +export function redactVideoTranscriptFieldsForLog(body: unknown): unknown { + if (!isPlainRecord(body)) return body; + const cloned = structuredClone(body) as UnknownRecord; + for (const key of CONTAINER_KEYS) { + redactContainer(cloned[key]); + } + return cloned; +} + +interface ClientRawRequestLike { + endpoint: unknown; + body: unknown; + headers?: unknown; +} + +interface RequestLoggerLike { + logClientRawRequest: (endpoint: unknown, body: unknown, headers?: unknown) => void; +} + +/** + * Call-site wrapper for `reqLogger.logClientRawRequest` (chatCore.ts's "0. Log client raw + * request" step): keeps the null-check and the observed/redacted guard out of chatCore.ts, + * which is a size-frozen file (`config/quality/file-size-baseline.json`) — this owns the + * redaction, so it owns the one guarded call site that applies it. Behavior is identical to + * the inline block it replaces: a non-observed request logs `clientRawRequest.body` by the + * exact same reference (no clone); an observed one logs the redacted clone. + */ +export function logClientRawRequestRedacted( + reqLogger: RequestLoggerLike, + clientRawRequest: ClientRawRequestLike | null | undefined, + videoBridgeObserved: boolean +): void { + if (!clientRawRequest) return; + reqLogger.logClientRawRequest( + clientRawRequest.endpoint, + videoBridgeObserved + ? redactVideoTranscriptFieldsForLog(clientRawRequest.body) + : clientRawRequest.body, + clientRawRequest.headers + ); +} + +/** + * Call-site wrapper for the `clientRequest` field stored by `trackPendingRequest` + * (open-sse/handlers/chatCore.ts): the sibling in-memory leak to + * `logClientRawRequestRedacted` above — same raw body, but live-exposed via + * /api/usage/call-logs (pendingDetails), /api/logs/[id] and /api/conversations + * while the request is in-flight, not just in the persisted detailed-log + * snapshot. Identical observed/non-observed branching: a non-observed request + * keeps the exact same reference (no clone); an observed one gets the redacted + * clone. + */ +export function redactPendingBody(clientRequest: unknown, videoBridgeObserved: boolean): unknown { + return videoBridgeObserved ? redactVideoTranscriptFieldsForLog(clientRequest) : clientRequest; +} diff --git a/src/lib/providerModels/cursorAutoCatalog.ts b/src/lib/providerModels/cursorAutoCatalog.ts index a9c0f2dca2..ddea216560 100644 --- a/src/lib/providerModels/cursorAutoCatalog.ts +++ b/src/lib/providerModels/cursorAutoCatalog.ts @@ -8,7 +8,6 @@ export type CursorAutoCatalogEntry = { id: string; name: string; owned_by?: string; - [key: string]: unknown; }; export const CURSOR_AUTO_ROUTER_VARIANT_IDS = [ @@ -26,10 +25,56 @@ const CURSOR_AUTO_ROUTER_VARIANT_NAMES: Record< "auto-intelligence": "Auto (intelligence)", }; +const CURSOR_ONE_MILLION_CONTEXT = 1_000_000; +const CURSOR_CONTEXT_EFFORT = "(?:low|medium|high|xhigh|max)"; +const CURSOR_ONE_MILLION_MODEL_PATTERNS = [ + new RegExp(`^claude-fable-5-1-thinking-${CURSOR_CONTEXT_EFFORT}$`), + new RegExp(`^claude-opus-5-(?:thinking-)?${CURSOR_CONTEXT_EFFORT}(?:-fast)?$`), + new RegExp(`^claude-opus-4-8-(?:thinking-)?${CURSOR_CONTEXT_EFFORT}(?:-fast)?$`), + new RegExp(`^claude-sonnet-5-(?:thinking-)?${CURSOR_CONTEXT_EFFORT}$`), + new RegExp(`^claude-4\\.6-sonnet-${CURSOR_CONTEXT_EFFORT}(?:-thinking)?$`), + new RegExp(`^gpt-5\\.6-(?:sol|terra|luna)-(?:none|${CURSOR_CONTEXT_EFFORT})$`), +] as const; + +const CURSOR_CONTEXT_FAMILY_NAMES = [ + "Claude Fable 5.1", + "Claude Opus 5", + "Claude Opus 4.8", + "Claude Sonnet 5", + "Claude Sonnet 4.6", + "GPT-5.6 Sol", + "GPT-5.6 Terra", + "GPT-5.6 Luna", +] as const; + +function supportsCursorOneMillionContext(id: string): boolean { + return CURSOR_ONE_MILLION_MODEL_PATTERNS.some((pattern) => pattern.test(id)); +} + +function oneMillionDisplayName(name: string): string { + const family = CURSOR_CONTEXT_FAMILY_NAMES.find((candidate) => name.startsWith(candidate)); + return family ? `${family} 1M${name.slice(family.length)}` : `${name} 1M`; +} + /** Cursor auto-router: catalog id `auto`, wire id `default`. Always keep `auto` visible. */ export function ensureCursorAutoCatalogEntry(models: T[]): T[] { const byId = new Map(models.map((m) => [m.id, m])); - const out = [...models]; + const out: T[] = []; + + for (const model of models) { + const oneMillionId = `${model.id}-1m`; + if (supportsCursorOneMillionContext(model.id) && !byId.has(oneMillionId)) { + const oneMillionEntry = { + ...model, + id: oneMillionId, + name: oneMillionDisplayName(model.name), + contextLength: CURSOR_ONE_MILLION_CONTEXT, + } as T; + out.push(oneMillionEntry); + byId.set(oneMillionId, oneMillionEntry); + } + out.push(model); + } if (!byId.has("auto")) { const defaultEntry = byId.get("default"); diff --git a/src/lib/providerModels/cursorAvailableModels.ts b/src/lib/providerModels/cursorAvailableModels.ts index 0fdc3d1939..8c4eb7b04a 100644 --- a/src/lib/providerModels/cursorAvailableModels.ts +++ b/src/lib/providerModels/cursorAvailableModels.ts @@ -9,8 +9,11 @@ import { humanizeCursorModelId, type CursorAgentModelEntry, } from "@/lib/providerModels/cursorAgent"; +import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; import { getConsistentMachineId } from "@/shared/utils/machineId"; +export { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; + export type FetchCursorAvailableModelsOptions = { accessToken: string; machineId?: string | null; @@ -40,6 +43,45 @@ function pickModelName(entry: Record, id: string): string { return humanizeCursorModelId(id); } +function collectArrays(record: Record, keys: string[]): unknown[] { + return keys.flatMap((key) => (Array.isArray(record[key]) ? record[key] : [])); +} + +function collectModelCandidates(payload: unknown): unknown[] { + const root = asRecord(payload) ?? {}; + const candidates = collectArrays(root, [ + "models", + "availableModels", + "available_models", + "model", + ]); + const nestedModels = asRecord(root.models); + if (nestedModels) candidates.push(...collectArrays(nestedModels, ["models", "items", "list"])); + if (Array.isArray(payload)) candidates.push(...payload); + return candidates; +} + +function isUnavailableModel(entry: Record): boolean { + return ( + entry.disabled === true || + entry.isDisabled === true || + entry.usable === false || + entry.isUsable === false + ); +} + +function normalizeModelCandidate(item: unknown): CursorAgentModelEntry | null { + if (typeof item === "string") { + const id = item.trim(); + return id ? { id, name: humanizeCursorModelId(id), owned_by: "cursor" } : null; + } + + const entry = asRecord(item); + if (!entry || isUnavailableModel(entry)) return null; + const id = pickModelId(entry); + return id ? { id, name: pickModelName(entry, id), owned_by: "cursor" } : null; +} + /** * Normalize AvailableModels JSON (Connect JSON or protobuf-json) into catalog rows. * Exported for unit tests. @@ -48,99 +90,18 @@ function pickModelName(entry: Record, id: string): string { * only). OmniRoute clients request `cu/auto`; resolveRequestedModel maps it to `default`. */ export function normalizeCursorAvailableModelsPayload(payload: unknown): CursorAgentModelEntry[] { - const root = asRecord(payload) ?? {}; - const candidates: unknown[] = []; - - for (const key of ["models", "availableModels", "available_models", "model"]) { - const v = root[key]; - if (Array.isArray(v)) candidates.push(...v); - } - - // Some Connect JSON responses nest under `models.models` or similar - const nestedModels = asRecord(root.models); - if (nestedModels) { - for (const key of ["models", "items", "list"]) { - const v = nestedModels[key]; - if (Array.isArray(v)) candidates.push(...v); - } - } - - if (Array.isArray(payload)) candidates.push(...payload); - const seen = new Set(); const out: CursorAgentModelEntry[] = []; - for (const item of candidates) { - if (typeof item === "string" && item.trim()) { - const id = item.trim(); - if (seen.has(id)) continue; - seen.add(id); - out.push({ id, name: humanizeCursorModelId(id), owned_by: "cursor" }); - continue; - } - const rec = asRecord(item); - if (!rec) continue; - const id = pickModelId(rec); - if (!id || seen.has(id)) continue; - // Prefer usable / non-disabled when flags exist - if (rec.disabled === true || rec.isDisabled === true) continue; - if (rec.usable === false || rec.isUsable === false) continue; - seen.add(id); - out.push({ id, name: pickModelName(rec, id), owned_by: "cursor" }); + for (const item of collectModelCandidates(payload)) { + const model = normalizeModelCandidate(item); + if (!model || seen.has(model.id)) continue; + seen.add(model.id); + out.push(model); } return ensureCursorAutoCatalogEntry(out); } -/** OpenCodex-style Cursor Router optimization modes (catalog ids). */ -export const CURSOR_AUTO_ROUTER_VARIANT_IDS = [ - "auto-cost", - "auto-balance", - "auto-intelligence", -] as const; - -const CURSOR_AUTO_ROUTER_VARIANT_NAMES: Record< - (typeof CURSOR_AUTO_ROUTER_VARIANT_IDS)[number], - string -> = { - "auto-cost": "Auto (cost)", - "auto-balance": "Auto (balance)", - "auto-intelligence": "Auto (intelligence)", -}; - -/** Cursor auto-router: catalog id `auto`, wire id `default`. Always keep `auto` visible. */ -export function ensureCursorAutoCatalogEntry( - models: CursorAgentModelEntry[] -): CursorAgentModelEntry[] { - const byId = new Map(models.map((m) => [m.id, m])); - const out = [...models]; - - if (!byId.has("auto")) { - const defaultEntry = byId.get("default"); - const autoEntry: CursorAgentModelEntry = { - id: "auto", - name: defaultEntry?.name || "Auto (current, default)", - owned_by: "cursor", - }; - // Prefer `auto` as the public id; keep `default` for wire-compat listings. - out.unshift(autoEntry); - byId.set("auto", autoEntry); - } - - // Always expose Cost/Balance/Intelligence router modes (OpenCodex CURSOR_ROUTER_MODEL_IDS). - for (const id of CURSOR_AUTO_ROUTER_VARIANT_IDS) { - if (byId.has(id)) continue; - const entry: CursorAgentModelEntry = { - id, - name: CURSOR_AUTO_ROUTER_VARIANT_NAMES[id], - owned_by: "cursor", - }; - out.push(entry); - byId.set(id, entry); - } - - return out; -} - export async function fetchCursorAvailableModels( options: FetchCursorAvailableModelsOptions ): Promise { diff --git a/src/lib/providers/nvidiaValidationModel.ts b/src/lib/providers/nvidiaValidationModel.ts index bde123944b..07ef6291b4 100644 --- a/src/lib/providers/nvidiaValidationModel.ts +++ b/src/lib/providers/nvidiaValidationModel.ts @@ -9,11 +9,12 @@ * probe HANG until the validation timeout, which surfaces as a misleading "Upstream * Error" on an otherwise-valid key. * - * `meta/llama-3.1-8b-instruct` is a long-lived, universally-available NIM model (no - * special permission), so it is a far more reliable auth probe. A connection may still - * override it via `providerSpecificData.validationModelId`. + * The default must stay inside the current NVIDIA hosted-model catalog. Nemotron 3.5 + * Lightning is the smallest retained general chat model, which keeps the auth probe + * lightweight. A connection may still override it via + * `providerSpecificData.validationModelId`. */ -export const NVIDIA_DEFAULT_VALIDATION_MODEL = "meta/llama-3.1-8b-instruct"; +export const NVIDIA_DEFAULT_VALIDATION_MODEL = "nvidia/nemotron-3.5-lightning-30b-a3b"; export function resolveNvidiaValidationModel(providerSpecificData?: { validationModelId?: unknown; diff --git a/src/lib/providers/staticModels.ts b/src/lib/providers/staticModels.ts index 63eebdce28..5bb6598db5 100644 --- a/src/lib/providers/staticModels.ts +++ b/src/lib/providers/staticModels.ts @@ -35,6 +35,7 @@ const STATIC_MODEL_PROVIDERS: Record Array<{ id: string; name: str ], antigravity: () => ANTIGRAVITY_PUBLIC_MODELS.map((model) => ({ ...model })), claude: () => [ + { id: "claude-fable-5-1", name: "Claude Fable 5.1" }, { id: "claude-fable-5", name: "Claude Fable 5" }, { id: "claude-opus-5", name: "Claude Opus 5" }, { id: "claude-opus-4-8", name: "Claude Opus 4.8" }, diff --git a/src/lib/providers/validation/chatgptWebCodex.ts b/src/lib/providers/validation/chatgptWebCodex.ts index 3dd6eda748..d95c1475f3 100644 --- a/src/lib/providers/validation/chatgptWebCodex.ts +++ b/src/lib/providers/validation/chatgptWebCodex.ts @@ -4,7 +4,6 @@ import { rmSync } from "node:fs"; import { CHATGPT_WEB_CODEX_CONNECTOR_NAME } from "@/shared/constants/chatgptWebCodex"; import { inspectBrowserLoginCapabilities } from "@omniroute/open-sse/vendor/codex-chatgpt-web/browser-login.ts"; import { decodeChatGptWebCodexSecrets } from "@omniroute/open-sse/executors/chatgpt-web-codex/credentials.ts"; -import { detectChromeExecutable } from "@omniroute/open-sse/executors/chatgpt-web-codex.ts"; import { connectionRuntimePaths, ensureConnectionStorageState, @@ -12,6 +11,16 @@ import { } from "@omniroute/open-sse/executors/chatgpt-web-codex/storageState.ts"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +// detectChromeExecutable (executors/chatgpt-web-codex.ts) is imported +// dynamically below, not statically here: this module is re-exported through +// the shared `@/lib/providers/validation` barrel that every provider +// validator's callers pull in, and executors/chatgpt-web-codex.ts's own +// import chain (its vendor browser adapter -> token-estimate.ts -> tiktoken's +// WASM tokenizer) fails to bundle under Turbopack dev mode even with +// `tiktoken` server-externalized -- turning validation of an unrelated +// provider into a route-wide crash for anyone who merely imports the barrel. +// A static import here evaluates that whole chain unconditionally. + export async function validateChatGptWebCodexProvider({ apiKey, providerSpecificData = {}, @@ -54,6 +63,8 @@ export async function validateChatGptWebCodexProvider({ }; } const cdpEndpoint = process.env.CHATGPT_WEB_CODEX_CDP_URL?.trim(); + const { detectChromeExecutable } = + await import("@omniroute/open-sse/executors/chatgpt-web-codex.ts"); const chromeExecutablePath = detectChromeExecutable( typeof providerSpecificData.chromeExecutablePath === "string" ? providerSpecificData.chromeExecutablePath diff --git a/src/lib/usage/apiKeySelfService.ts b/src/lib/usage/apiKeySelfService.ts index 2e9dc9ffa7..7bc2d440ca 100644 --- a/src/lib/usage/apiKeySelfService.ts +++ b/src/lib/usage/apiKeySelfService.ts @@ -1,5 +1,5 @@ import { hasSelfAccountQuotaScope, hasSelfUsageScope } from "@/shared/constants/selfServiceScopes"; -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { supportsProviderQuota } from "@/shared/utils/providerQuotaVisibility"; type JsonRecord = Record; type DateLike = number | string | Date | null | undefined; @@ -64,6 +64,7 @@ interface AccountQuotaConnection { id: string; provider: string; lookupFailed?: boolean; + providerSpecificData?: unknown; } function toNumber(value: unknown, fallback = 0): number { @@ -208,11 +209,14 @@ function normalizePlan(value: unknown): unknown { return undefined; } -function isSupportedProvider(provider: string): boolean { - return USAGE_SUPPORTED_PROVIDERS.includes(provider as (typeof USAGE_SUPPORTED_PROVIDERS)[number]); +function isSupportedProvider( + provider: string, + connection?: { provider?: string; providerSpecificData?: unknown }, +): boolean { + return supportsProviderQuota(provider, connection); } -function getConnectionIdentity(value: unknown): { id: string; provider: string } | null { +function getConnectionIdentity(value: unknown): AccountQuotaConnection | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as JsonRecord; if (record.isActive === false) return null; @@ -221,7 +225,11 @@ function getConnectionIdentity(value: unknown): { id: string; provider: string } const provider = typeof record.provider === "string" ? record.provider : ""; if (!id || !provider) return null; - return { id, provider }; + return { + id, + provider, + providerSpecificData: record.providerSpecificData, + }; } async function listAccountQuotaConnections( @@ -297,7 +305,7 @@ async function resolveConnectionAccountQuota( }; } - if (!isSupportedProvider(connection.provider)) { + if (!isSupportedProvider(connection.provider, connection)) { return { provider: connection.provider, connectionId: connection.id, diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 9a36d5e491..2e0b8895da 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -16,7 +16,7 @@ import { setQuotaCache } from "@/domain/quotaCache"; import { buildClaudeExtraUsageConnectionUpdate } from "@/lib/providers/claudeExtraUsage"; import { clearRecoveredProviderState } from "@/sse/services/auth"; import { getMachineId } from "@/shared/utils/machine"; -import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { supportsProviderQuota } from "@/shared/utils/providerQuotaVisibility"; import { mergeProviderLimitsCacheEntry, toProviderLimitsCacheEntry } from "./providerLimitsCache"; import { getCredentialRefreshExecutor } from "@omniroute/open-sse/executors/credential.ts"; import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts"; @@ -174,19 +174,14 @@ function shouldRefreshProviderLimitsCache( } export function isSupportedUsageConnection(connection: ProviderConnectionLike | null): boolean { - if ( - !connection || - !connection.provider || - !USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) - ) { - return false; - } + if (!connection?.provider) return false; - if (connection.authType === "oauth") return true; - return ( - (connection.authType === "apikey" || connection.authType === "api_key") && - PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider) - ); + if (connection.authType === "oauth") { + return supportsProviderQuota(connection.provider, connection); + } + if (connection.authType !== "apikey" && connection.authType !== "api_key") return false; + if (PROVIDER_LIMITS_APIKEY_PROVIDERS.has(connection.provider)) return true; + return supportsProviderQuota(connection.provider, connection); } function withStatus(error: Error, status: number): Error & { status: number } { diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts index 60a8d8e15a..b987f4fe49 100644 --- a/src/server/authz/routeGuard.ts +++ b/src/server/authz/routeGuard.ts @@ -140,6 +140,42 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray = [ // which is false under requireLogin=false. (GHSA-v7g9-7f55-5g46) "/api/settings/export-json", "/api/settings/import-json", + // Bulk log export: call_logs carries prompts and responses, proxy_logs carries + // client/public IPs, and the handler only calls requireManagementAuth() with no + // alwaysRequireAuth. Found sweeping the GHSA-5926-2w35-7h4q class. + "/api/logs/export", + // Codex CLI profile store. GET leaks the operator's account label; PUT writes + // attacker-supplied auth.json + config.toml straight into the operator's Codex + // CLI config (ensureCliConfigWriteAllowed() only checks CLI_ALLOW_CONFIG_WRITES, + // which defaults to true), so a POST+PUT pair repoints the CLI at attacker + // credentials or an attacker base URL. Found sweeping the same class. + "/api/cli-tools/codex-profiles", + // Writes into ~/.gemini/antigravity-cli/antigravity-oauth-token. Same family + // as the {claude,codex}-auth/apply-local pattern below; a plain path because + // it carries no dynamic segment. + "/api/providers/agy-auth/apply-local", +]; + +/** + * ALWAYS_PROTECTED routes whose path carries a dynamic segment, so the plain + * exact/prefix list above cannot express them: a `/api/providers/` prefix would + * hard-gate the entire provider surface and break every keyless local-first + * install. Mirrors LOCAL_ONLY_API_PATTERNS. + * + * The Claude/Codex OAuth export routes return the connection's raw + * access_token / refresh_token (and the Codex id_token) and gate only on + * `requireManagementAuth(request)` with no `alwaysRequireAuth`, which fails open + * under requireLogin=false (GHSA-5926-2w35-7h4q). They are the siblings that + * both GHSA-mghq-58h3-qcqj and GHSA-v7g9-7f55-5g46 missed. + */ +export const ALWAYS_PROTECTED_API_PATTERNS: ReadonlyArray = [ + // `export` hands the caller the raw token; `apply-local` writes it into the + // host's CLI config (~/.codex/auth.json and the Claude equivalent). The second + // does not disclose the credential, but "anonymous" is still the wrong + // audience for it. ALWAYS_PROTECTED rather than LOCAL_ONLY on purpose: it + // closes the anonymous hole without breaking an operator driving the dashboard + // through a tunnel. + /^\/api\/providers\/[^/]+\/(claude|codex)-auth\/(export|apply-local)\/?$/, ]; export function isLoopbackHost(hostHeader: string | null): boolean { @@ -295,5 +331,8 @@ export function isLocalOnlyBypassableByManageScope(path: string): boolean { } export function isAlwaysProtectedPath(path: string): boolean { - return ALWAYS_PROTECTED_API_PATHS.some((p) => path === p || path.startsWith(p)); + return ( + ALWAYS_PROTECTED_API_PATHS.some((p) => path === p || path.startsWith(p)) || + ALWAYS_PROTECTED_API_PATTERNS.some((re) => re.test(path)) + ); } diff --git a/src/shared/components/RequestLoggerDetail.sections.tsx b/src/shared/components/RequestLoggerDetail.sections.tsx index e7a145638d..78527ab495 100644 --- a/src/shared/components/RequestLoggerDetail.sections.tsx +++ b/src/shared/components/RequestLoggerDetail.sections.tsx @@ -259,6 +259,20 @@ export function ConversationContextSection({ log, detail }) { {open ? "expand_less" : "expand_more"} + {liveDetail?.parentLogId && ( + // Full navigation, not client-side routing: the logs page only reads + // ?id from a fresh mount (useState(() => searchParams.get("id")) in + // dashboard/logs/page.tsx), so an in-page route change wouldn't load + // the parent entry if the user is already on this page. + + reply + continues from parent + + )}
{open && (
diff --git a/src/shared/constants/claudeCodeClient.ts b/src/shared/constants/claudeCodeClient.ts index 415068bc2d..dd72f6246e 100644 --- a/src/shared/constants/claudeCodeClient.ts +++ b/src/shared/constants/claudeCodeClient.ts @@ -4,10 +4,10 @@ * Keep this leaf dependency-free so server executors, compatibility bridges, * and client-facing identity presets can share one source of truth. */ -export const CLAUDE_CODE_CLIENT_VERSION = "2.1.220"; -export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "1f2"; +export const CLAUDE_CODE_CLIENT_VERSION = "2.1.258"; +export const CLAUDE_CODE_CLIENT_BUILD_REVISION = "1e2"; export const CLAUDE_CODE_CLIENT_BILLING_VERSION = `${CLAUDE_CODE_CLIENT_VERSION}.${CLAUDE_CODE_CLIENT_BUILD_REVISION}`; -export const CLAUDE_CODE_SDK_PACKAGE_VERSION = "0.94.0"; +export const CLAUDE_CODE_SDK_PACKAGE_VERSION = "0.112.1"; export const CLAUDE_CODE_RUNTIME_VERSION = "v26.3.0"; export type ClaudeCodeEntrypoint = "cli" | "sdk-cli"; diff --git a/src/shared/constants/cliTools.ts b/src/shared/constants/cliTools.ts index 95d956356b..01401ea986 100644 --- a/src/shared/constants/cliTools.ts +++ b/src/shared/constants/cliTools.ts @@ -45,7 +45,7 @@ export const CLI_TOOLS: Record = { name: "Claude Fable", alias: "fable", envKey: "ANTHROPIC_DEFAULT_FABLE_MODEL", - defaultValue: _cc.fable ? `cc/${_cc.fable}` : "cc/claude-fable-5", + defaultValue: _cc.fable ? `cc/${_cc.fable}` : "cc/claude-fable-5-1", isTopLevel: true, }, { diff --git a/src/shared/constants/config.ts b/src/shared/constants/config.ts index 0810042444..8e21b9c0dd 100644 --- a/src/shared/constants/config.ts +++ b/src/shared/constants/config.ts @@ -2,6 +2,7 @@ export { APP_CONFIG, THEME_CONFIG } from "./appConfig"; // Provider API endpoints (for display only) export const PROVIDER_ENDPOINTS = { + seekai: "https://seekai.cc/v1/chat/completions", agentrouter: "https://agentrouter.org/v1/chat/completions", openrouter: "https://openrouter.ai/api/v1/chat/completions", dgrid: "https://api.dgrid.ai/v1/chat/completions", diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index fcc96c8e9b..c8b6faaf4b 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -24,9 +24,13 @@ export interface ModelSpec { // Model ONLY supports adaptive thinking: manual extended thinking was removed. Sending // `thinking.type:"enabled"` or any `thinking.budget_tokens` returns HTTP 400; reasoning // is steered exclusively by `output_config.effort` (low/medium/high/xhigh/max). True for - // Claude Opus 4.7 and later (Opus 4.7/4.8/5, Fable 5). Per Anthropic's migration guide, + // Claude Opus 4.7 and later (Opus 4.7/4.8/5, Fable 5/5.1). Per Anthropic's migration guide, // any request that tries to set a fixed thinking budget gets a 400 error. adaptiveThinkingOnly?: boolean; + // The model rejects tool_choice values that require a tool call. Keep tools available, + // but normalize a forced choice to the default auto behavior before dispatch. Fable 5.1 always runs + // adaptive thinking, so forced tool use cannot be combined with any valid request. + rejectsForcedToolChoice?: boolean; // Highest effort accepted while `thinking.type:"disabled"` is present. Claude Opus 5 // rejects disabled thinking with xhigh/max, while accepting it through high. maxEffortWhenThinkingDisabled?: "high"; @@ -371,6 +375,21 @@ export const MODEL_SPECS: Record = { aliases: BEDROCK_CLAUDE_ALIASES("claude-opus-4-7", "claude-opus-4.7"), }, + // ── Claude Fable 5.1 ──────────────────────────────────────────── + "claude-fable-5-1": { + maxOutputTokens: 128000, + contextWindow: 1000000, + defaultThinkingBudget: 32000, + thinkingBudgetCap: 120000, + supportsThinking: true, + supportsTools: true, + supportsVision: true, + rejectsThinkingDisabled: true, + adaptiveThinkingOnly: true, + rejectsForcedToolChoice: true, + aliases: BEDROCK_CLAUDE_ALIASES("claude-fable-5-1"), + }, + // ── Claude Fable 5 ────────────────────────────────────────────── "claude-fable-5": { maxOutputTokens: 128000, @@ -849,9 +868,39 @@ export function normalizeThinkingForModel>( getModelSpec(modelId)?.rejectsThinkingDisabled ) { const { thinking: _omitted, ...rest } = body as Record; - return rest as T; + return normalizeForcedToolChoiceForModel(rest as T, modelId); } - return body; + return normalizeForcedToolChoiceForModel(body, modelId); +} + +/** + * Normalize tool-choice constraints that a resolved model cannot accept. + * + * Claude Fable 5.1 always uses adaptive thinking and rejects tool choices that force + * either any tool or one named tool. Preserve the declared tools and every unrelated + * request field, but drop the choice to select the default `auto` behavior so routing a + * request to Fable 5.1 does not turn a recoverable preference into an upstream 400. + */ +export function normalizeForcedToolChoiceForModel>( + body: T, + modelId: string +): T { + if (!getModelSpec(modelId)?.rejectsForcedToolChoice) return body; + + const toolChoice = body.tool_choice; + const forced = + toolChoice === "required" || + toolChoice === "any" || + (toolChoice !== null && + typeof toolChoice === "object" && + !Array.isArray(toolChoice) && + ["any", "tool", "function"].includes( + String((toolChoice as Record).type || "").toLowerCase() + )); + if (!forced) return body; + + const { tool_choice: _omitted, ...rest } = body; + return rest as T; } export function capMaxOutputTokens(modelId: string, requested?: number): number | undefined { diff --git a/src/shared/constants/pricing/default-pricing.ts b/src/shared/constants/pricing/default-pricing.ts index 08b84a86e9..a36654bd06 100644 --- a/src/shared/constants/pricing/default-pricing.ts +++ b/src/shared/constants/pricing/default-pricing.ts @@ -7,10 +7,12 @@ import { DEFAULT_PRICING_OAUTH } from "./oauth-subscriptions"; import { DEFAULT_PRICING_FRONTIER } from "./frontier-labs"; import { DEFAULT_PRICING_INFERENCE } from "./inference-hosts"; import { DEFAULT_PRICING_REGIONAL } from "./regional"; +import { DEFAULT_PRICING_DEVIN } from "./devin"; export const DEFAULT_PRICING = { ...DEFAULT_PRICING_OAUTH, ...DEFAULT_PRICING_FRONTIER, ...DEFAULT_PRICING_INFERENCE, ...DEFAULT_PRICING_REGIONAL, + ...DEFAULT_PRICING_DEVIN, }; diff --git a/src/shared/constants/pricing/devin.ts b/src/shared/constants/pricing/devin.ts new file mode 100644 index 0000000000..36a0e49b3a --- /dev/null +++ b/src/shared/constants/pricing/devin.ts @@ -0,0 +1,142 @@ +type DevinTokenPricing = { + input: number; + cached: number; + output: number; +}; + +const QUALITY_EFFORTS = ["max", "xhigh", "high", "medium", "low"] as const; +const GPT_EFFORTS = ["max", "xhigh", "high", "medium", "low", "none"] as const; + +function variantIds(base: string, efforts: readonly string[]): string[] { + return efforts.map((effort) => `${base}-${effort}`); +} + +function fastVariantIds(base: string): string[] { + return QUALITY_EFFORTS.map((effort) => `${base}-${effort}-fast`); +} + +function priorityVariantIds(base: string): string[] { + return GPT_EFFORTS.map((effort) => `${base}-${effort}-priority`); +} + +function priced(ids: readonly string[], pricing: DevinTokenPricing) { + return Object.fromEntries(ids.map((id) => [id, pricing])); +} + +const CLAUDE_FABLE_5_1 = { input: 10, cached: 0.25, output: 50 }; +const CLAUDE_OPUS = { input: 5, cached: 0.5, output: 25 }; +const CLAUDE_OPUS_FAST = { input: 10, cached: 1, output: 50 }; +const CLAUDE_SONNET_5 = { input: 2, cached: 0.2, output: 10 }; +const CLAUDE_SONNET_4_6 = { input: 3, cached: 0.3, output: 15 }; +const CLAUDE_HAIKU_4_5 = { input: 1, cached: 0.1, output: 5 }; + +const GPT_5_6_SOL = { input: 4, cached: 0.4, output: 20 }; +const GPT_5_6_SOL_FAST = { input: 8, cached: 0.8, output: 40 }; +const GPT_5_6_TERRA = { input: 2, cached: 0.2, output: 12 }; +const GPT_5_6_TERRA_FAST = { input: 4, cached: 0.4, output: 24 }; +const GPT_5_6_LUNA = { input: 0.2, cached: 0.02, output: 1.2 }; +const GPT_5_6_LUNA_FAST = { input: 0.4, cached: 0.04, output: 2.4 }; + +/** + * Exact per-UID rates returned by Devin's authenticated live catalog on + * 2026-09-02. Rates are USD per one million tokens. + */ +export const DEVIN_MODEL_PRICING: Record = { + ...priced(variantIds("claude-fable-5-1", QUALITY_EFFORTS), CLAUDE_FABLE_5_1), + ...priced(variantIds("claude-opus-5", QUALITY_EFFORTS), CLAUDE_OPUS), + ...priced(fastVariantIds("claude-opus-5"), CLAUDE_OPUS_FAST), + ...priced(variantIds("claude-opus-4-8", QUALITY_EFFORTS), CLAUDE_OPUS), + ...priced(fastVariantIds("claude-opus-4-8"), CLAUDE_OPUS_FAST), + ...priced(variantIds("claude-sonnet-5", QUALITY_EFFORTS), CLAUDE_SONNET_5), + ...priced( + [ + "claude-sonnet-4-6", + "claude-sonnet-4-6-thinking", + "claude-sonnet-4-6-1m", + "claude-sonnet-4-6-thinking-1m", + ], + CLAUDE_SONNET_4_6 + ), + MODEL_PRIVATE_11: CLAUDE_HAIKU_4_5, + + ...priced(variantIds("gpt-5-6-sol", GPT_EFFORTS), GPT_5_6_SOL), + ...priced(priorityVariantIds("gpt-5-6-sol"), GPT_5_6_SOL_FAST), + ...priced(variantIds("gpt-5-6-terra", GPT_EFFORTS), GPT_5_6_TERRA), + ...priced(priorityVariantIds("gpt-5-6-terra"), GPT_5_6_TERRA_FAST), + ...priced(variantIds("gpt-5-6-luna", GPT_EFFORTS), GPT_5_6_LUNA), + ...priced(priorityVariantIds("gpt-5-6-luna"), GPT_5_6_LUNA_FAST), + + ...priced(variantIds("kimi-k3", ["max", "high", "low"]), { + input: 3, + cached: 0.3, + output: 15, + }), + "kimi-k2-7": { input: 0.95, cached: 0.19, output: 4 }, + ...priced(variantIds("glm-5-3", ["max", "high", "low"]), { + input: 1.4, + cached: 0.26, + output: 4.4, + }), + ...priced(variantIds("glm-5-3-flash", ["max", "high", "low"]), { + input: 0.15, + cached: 0.03, + output: 0.5, + }), + + ...priced(["swe-1-7", "swe-1-7-medium"], { + input: 0.5, + cached: 0.2, + output: 2.5, + }), + ...priced(["swe-1-7-lightning", "swe-1-7-lightning-medium"], { + input: 2.5, + cached: 1, + output: 12.5, + }), + adaptive: { input: 0.5, cached: 0.1, output: 2 }, + ...priced(variantIds("grok-4-6", ["xhigh", "high", "medium", "low"]), { + input: 2, + cached: 0.3, + output: 6, + }), + ...priced(variantIds("inkling", ["max", "xhigh", "high", "medium", "low", "none"]), { + input: 1.4, + cached: 0.26, + output: 4.4, + }), + ...priced(variantIds("deepseek-v4-flash", ["max", "high", "low"]), { + input: 0.14, + cached: 0.03, + output: 0.28, + }), + ...priced(variantIds("nemotron-3-ultra", ["high", "medium", "none"]), { + input: 0.6, + cached: 0.12, + output: 2.4, + }), + ...priced(variantIds("gemini-3-7-flash", ["high", "medium", "low"]), { + input: 1.5, + cached: 0.15, + output: 7.5, + }), + ...priced(variantIds("gemini-3-1-pro", ["high", "low"]), { + input: 2, + cached: 0.2, + output: 12, + }), + ...priced(variantIds("deepseek-v4-pro", ["max", "high", "low"]), { + input: 1.32, + cached: 0.04, + output: 3.96, + }), +}; + +// Each transport gets its own provider namespace. They share today's upstream +// rate snapshot, but can diverge independently if Devin changes one channel. +export const DEFAULT_PRICING_DEVIN = { + "devin-cli": { ...DEVIN_MODEL_PRICING }, + dv: { ...DEVIN_MODEL_PRICING }, + "devin-desktop": { ...DEVIN_MODEL_PRICING }, + "devin-cli-agentic": { ...DEVIN_MODEL_PRICING }, + dva: { ...DEVIN_MODEL_PRICING }, +}; diff --git a/src/shared/constants/pricing/frontier-labs.ts b/src/shared/constants/pricing/frontier-labs.ts index d20a187720..b8a80d3bf1 100644 --- a/src/shared/constants/pricing/frontier-labs.ts +++ b/src/shared/constants/pricing/frontier-labs.ts @@ -8,6 +8,7 @@ import { GPT_5_6_LUNA_PRICING, GPT_5_6_SOL_PRICING, GPT_5_6_TERRA_PRICING, + CLAUDE_FABLE_5_1_PRICING, CLAUDE_FABLE_5_PRICING, CLAUDE_OPUS_5_PRICING, CLAUDE_OPUS_4_PRICING, @@ -213,6 +214,7 @@ export const DEFAULT_PRICING_FRONTIER = { // Common model IDs (without dates) used across providers // Intentional duplicates of dot-notation variants (e.g. claude-opus-4.6) // to cover hyphen-notation IDs (claude-opus-4-6) used by some clients + "claude-fable-5-1": CLAUDE_FABLE_5_1_PRICING, "claude-fable-5": CLAUDE_FABLE_5_PRICING, "claude-opus-5": CLAUDE_OPUS_5_PRICING, "claude-sonnet-5": CLAUDE_SONNET_5_PRICING, diff --git a/src/shared/constants/pricing/oauth-subscriptions.ts b/src/shared/constants/pricing/oauth-subscriptions.ts index 87f5aa320f..9d255e0e24 100644 --- a/src/shared/constants/pricing/oauth-subscriptions.ts +++ b/src/shared/constants/pricing/oauth-subscriptions.ts @@ -3,6 +3,7 @@ * Pure data; merged by default-pricing.ts via spread (god-file decomposition; semantic split). */ import { + CLAUDE_FABLE_5_1_PRICING, CLAUDE_OPUS_5_PRICING, GEMINI_3_7_FLASH_PROMO_PRICING, GPT_5_3_CODEX_PRICING, @@ -20,6 +21,7 @@ const ANTIGRAVITY_GEMINI_3_7_PRICING = { export const DEFAULT_PRICING_OAUTH = { cc: { + "claude-fable-5-1": CLAUDE_FABLE_5_1_PRICING, "claude-fable-5": { input: 10.0, output: 50.0, diff --git a/src/shared/constants/pricing/shared-tiers.ts b/src/shared/constants/pricing/shared-tiers.ts index 542e03521a..7cb674df09 100644 --- a/src/shared/constants/pricing/shared-tiers.ts +++ b/src/shared/constants/pricing/shared-tiers.ts @@ -60,6 +60,14 @@ export const CLAUDE_FABLE_5_PRICING = { cache_creation: 15.0, }; +export const CLAUDE_FABLE_5_1_PRICING = { + input: 10.0, + output: 50.0, + cached: 0.25, + reasoning: 50.0, + cache_creation: 12.5, +}; + export const CLAUDE_OPUS_5_PRICING = { input: 5.0, output: 25.0, diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 7732808fbc..ef8d8ac94e 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -144,6 +144,7 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([ "helixmind", "tabitoken", "logfare", + "seekai", ]); export const ENTERPRISE_CLOUD_PROVIDER_IDS = new Set([ diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts index 8551f5daff..c1f87a6d75 100644 --- a/src/shared/constants/providers/apikey/gateways.ts +++ b/src/shared/constants/providers/apikey/gateways.ts @@ -1438,4 +1438,24 @@ export const APIKEY_PROVIDERS_GATEWAYS = { apiHint: "Create an sk- key at https://tabitoken.com and use https://tabitoken.com. The Anthropic-compatible /v1/messages endpoint (default) takes x-api-key; /v1/chat/completions takes Bearer.", }, + // SeekAi (https://seekai.cc) — QuantumNous New-API aggregator. Live-verified + // 2026-09-02: GET /api/status → system_name=SeekAi, version=v1.0.0-rc.25, + // quota_display_type=USD. OpenAI-compatible /v1; models discovered live. + seekai: { + id: "seekai", + serviceKinds: ["llm"], + alias: "ska", + name: "SeekAi", + icon: "hub", + color: "#0D9488", + textIcon: "SK", + passthroughModels: true, + website: "https://seekai.cc", + hasFree: true, + freeNote: "Signup credit toward available models; amount and eligibility are set by SeekAi, not OmniRoute.", + authHint: + "Create an API key at https://seekai.cc, then paste it here as a Bearer token.", + apiHint: + "Create an API key at https://seekai.cc, then paste it here as a Bearer token. OpenAI-compatible base URL: https://seekai.cc/v1.", + }, }; diff --git a/src/shared/constants/providers/apikey/regional.ts b/src/shared/constants/providers/apikey/regional.ts index a9c5701dfd..2c437cc4b3 100644 --- a/src/shared/constants/providers/apikey/regional.ts +++ b/src/shared/constants/providers/apikey/regional.ts @@ -494,7 +494,7 @@ export const APIKEY_PROVIDERS_REGIONAL = { textIcon: "CS", website: "https://api.ncloud-docs.com/docs/en/ai-naver-clovastudio-summary", apiHint: - "CLOVA Studio (HyperCLOVA X) is OpenAI-compatible on /v1/openai. OmniRoute probes /v1/openai/models and routes chat traffic to /v1/openai/chat/completions. Uses the current clovastudio.stream.ntruss.com host — the legacy clovastudio.apigw.ntruss.com endpoint is being deprecated.", + "OmniRoute routes chat traffic to the native Chat Completions v3 API (/v3/chat-completions/{model}), not the OpenAI-compatibility shim. All three v3 models are served: HCX-007 (reasoning, text only), HCX-005 (vision — accepts both public image URLs and inline base64 images), and HCX-DASH-002 (lightweight, text only). Requests stream upstream and are accumulated into a JSON body when the client asks for a non-streaming response.", }, internlm: { id: "internlm", diff --git a/src/shared/constants/visionModels.ts b/src/shared/constants/visionModels.ts index da7b755490..bcad514058 100644 --- a/src/shared/constants/visionModels.ts +++ b/src/shared/constants/visionModels.ts @@ -57,6 +57,11 @@ export const VISION_MODEL_ID_FRAGMENTS = [ "mistral-medium-3", "minimax-m3", "kimi-k2.", + // Naver CLOVA Studio: HCX-005 is the only v3 model with image input. Listed by + // exact id (not a family fragment) to stay conservative — live-verified on + // 2026-09-01 that it answers image prompts over both a public URL and a + // base64 data URI, while HCX-007 and HCX-DASH-002 reject images. + "hcx-005", "-vision", "multimodal", ] as const; diff --git a/src/shared/utils/classify429.ts b/src/shared/utils/classify429.ts index 03b6e41658..d482334778 100644 --- a/src/shared/utils/classify429.ts +++ b/src/shared/utils/classify429.ts @@ -91,6 +91,14 @@ const QUOTA_PATTERNS: ReadonlyArray = [ // Trailing punctuation/whitespace before the closing quote is tolerated // because real API responses may include a period or trailing space. /"error"\s*:\s*"usage limit reached[.\s]*"/i, + + // Moonshot Open Platform organization TPD (tokens-per-day). Live body: + // "request reached organization TPD rate limit, current: N, limit: M". + // Do not use a bare /TPD/ — too wide. Limit is read from the body, never + // hardcoded (Tier0=1.5M, Tier1+=unlimited). + /organization TPD rate limit/i, + /\bTPD rate limit\b/i, + /insufficient balance/i, ]; /** @@ -155,6 +163,9 @@ const TERMINAL_QUOTA_PATTERNS: ReadonlyArray = [ /individual quota reached/i, /enable overages/i, /daily free allocation/i, + /organization TPD rate limit/i, + /\bTPD rate limit\b/i, + /insufficient balance/i, ]; /** diff --git a/src/shared/utils/providerQuotaVisibility.ts b/src/shared/utils/providerQuotaVisibility.ts index 3968005204..81edcdc450 100644 --- a/src/shared/utils/providerQuotaVisibility.ts +++ b/src/shared/utils/providerQuotaVisibility.ts @@ -1,13 +1,23 @@ import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; +import { isMoonshotOpenPlatformConnection } from "@omniroute/open-sse/services/usage/moonshotOpenPlatform.ts"; export interface ProviderQuotaVisibilityConnection { quotaVisible?: boolean; + provider?: string; + providerSpecificData?: unknown; } export function isProviderQuotaVisible(connection: ProviderQuotaVisibilityConnection): boolean { return connection.quotaVisible !== false; } -export function supportsProviderQuota(providerId: string): boolean { - return USAGE_SUPPORTED_PROVIDERS.includes(providerId); +export function supportsProviderQuota( + providerId: string, + connection?: { provider?: string; providerSpecificData?: unknown }, +): boolean { + if (USAGE_SUPPORTED_PROVIDERS.includes(providerId)) return true; + return isMoonshotOpenPlatformConnection({ + provider: providerId, + providerSpecificData: connection?.providerSpecificData, + }); } diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index fa6f0ef055..c93e563ab7 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -22,16 +22,36 @@ import { isReservedProviderPrefix, reservedProviderPrefixMessage, } from "@/shared/constants/reservedProviderPrefixes"; - +import { + isValidIanaTimeZone, + isValidResetHour, +} from "@omniroute/open-sse/services/dailyQuotaReset.ts"; import { upstreamHeadersRecordSchema, modelCompatPerProtocolSchema, customHeadersSchema, } from "./misc.ts"; +import { isValidProviderIconUrl } from "@/shared/validation/iconUrl"; export { validateProviderSpecificData }; -import { isValidProviderIconUrl } from "@/shared/validation/iconUrl"; +const dailyQuotaResetTimezoneSchema = z + .string() + .trim() + .optional() + .or(z.literal("")) + .refine((value) => !value || isValidIanaTimeZone(value), { + message: "Unknown IANA timezone", + }); + +const dailyQuotaResetHourSchema = z + .number() + .int() + .optional() + .nullable() + .refine((value) => value == null || isValidResetHour(value), { + message: "Hour must be 0-23", + }); // ──── Provider Schemas ──── @@ -337,6 +357,8 @@ export const createProviderNodeSchema = z // isValidProviderIconUrl (2000 chars for http(s), 256 KiB for data:image). iconUrl: providerNodeIconUrlSchema, customHeaders: customHeadersSchema, + dailyQuotaResetTimezone: dailyQuotaResetTimezoneSchema, + dailyQuotaResetHour: dailyQuotaResetHourSchema, }) .superRefine((value, ctx) => { const nodeType = value.type || "openai-compatible"; @@ -419,6 +441,8 @@ export const updateProviderNodeSchema = z // clears a previously stored custom icon. iconUrl: providerNodeIconUrlSchema, customHeaders: customHeadersSchema, + dailyQuotaResetTimezone: dailyQuotaResetTimezoneSchema, + dailyQuotaResetHour: dailyQuotaResetHourSchema, }) .superRefine((value, ctx) => { // Reserved-prefix guard (tokenrouter bug) — same rationale as the guard in diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 55f1c49645..a07651d587 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -23,6 +23,7 @@ import { SPAWN_CAPABLE_PREFIXES, SPAWN_CAPABLE_PATTERN_ANCESTORS, } from "@/shared/constants/spawnCapablePrefixes"; +import { isHttpUrl } from "@/shared/validation/schemas/misc"; const signatureCacheModeValues = ["enabled", "bypass", "bypass-strict"] as const; @@ -493,6 +494,26 @@ export const updateSettingsSchema = z.object({ // CLIProxyAPI connection settings cliproxyapi_fallback_enabled: z.boolean().optional(), cliproxyapi_url: z.string().url().max(500).optional(), + // #12306: external Headroom proxy URL. Empty = fall back to HEADROOM_URL / localhost:8787. + // Status/start already read this key; without the schema field PATCH strips it. + // Trim first so a padded URL matches the client (isValidHeadroomUrl trims) + // and whitespace-only becomes the empty fallback, not "Invalid URL". + // z.string().url() also accepts javascript:/data:/file:. probeProxyRunning + // interpolates this into fetch(`${url}/health`), so restrict to http(s). + headroomUrl: z + .string() + .trim() + .pipe( + z.union([ + z.literal(""), + z + .string() + .url() + .max(500) + .refine((value) => isHttpUrl(value), "must be an http(s) URL"), + ]) + ) + .optional(), cliproxyapi_fallback_codes: z.string().max(200).optional(), // #7645: dedicated CLIProxyAPI credential. CLIProxyAPI requires its own // separately-configured `api-keys:` credential and rejects any other token diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 6c426bcfce..7ad2131076 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -174,6 +174,7 @@ import { registerBailianCodingPlanQuotaFetcher } from "@omniroute/open-sse/servi import { registerQwenTokenPlanQuotaFetcher } from "@omniroute/open-sse/services/qwenTokenPlanQuotaFetcher.ts"; import { registerCrofUsageFetcher } from "@omniroute/open-sse/services/crofUsageFetcher.ts"; import { registerDeepseekQuotaFetcher } from "@omniroute/open-sse/services/deepseekQuotaFetcher.ts"; +import { registerMoonshotQuotaFetcher, registerMoonshotFetchersForNodes } from "@omniroute/open-sse/services/moonshotQuotaFetcher.ts"; import { registerOpenrouterQuotaFetcher } from "@omniroute/open-sse/services/openrouterQuotaFetcher.ts"; import { registerOpencodeQuotaFetcher } from "@omniroute/open-sse/services/opencodeQuotaFetcher.ts"; import { registerGrokWebQuotaFetcher } from "@omniroute/open-sse/services/grokQuotaFetcher.ts"; @@ -221,6 +222,21 @@ registerCrofUsageFetcher(); // Register DeepSeek balance quota fetcher. // Hooks into quotaPreflight + quotaMonitor so combos can switch accounts before balance is exhausted. registerDeepseekQuotaFetcher(); +registerMoonshotQuotaFetcher(); +void import("@/lib/db/providers") + .then(({ getProviderNodes }) => getProviderNodes()) + .then((nodes) => { + registerMoonshotFetchersForNodes( + (Array.isArray(nodes) ? nodes : []).map((node) => ({ + id: typeof node.id === "string" ? node.id : null, + prefix: typeof node.prefix === "string" ? node.prefix : null, + baseUrl: typeof node.baseUrl === "string" ? node.baseUrl : null, + })), + ); + }) + .catch((error) => { + console.warn("[STARTUP] Moonshot custom-node fetcher scan skipped:", error); + }); registerOpenrouterQuotaFetcher(); // Register OpenCode quota fetcher (opencode-go / opencode / opencode-zen). diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index ec2b42a17d..b121876376 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -47,7 +47,12 @@ import { hydrateCodexQuotaCacheForRequest, isQuotaExhaustedForRequest, } from "@/domain/quotaCache"; -import { getQuotaScopeLabelForProvider } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { + getQuotaScopeLabelForProvider, + isAntigravityQuotaProvider, +} from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { rehydrateAntigravityFamilyLocksForConnections, persistAntigravityFamilyCooldownIfQuota } from "@omniroute/open-sse/services/antigravityFamilyCooldown.ts"; +import { markQuotaPreflightAccountUnavailable } from "./quotaPreflightUnavailable.ts"; import { getCreditsMode } from "@omniroute/open-sse/services/antigravityCredits.ts"; import { preferAntigravityConnectionsWithStoredProject } from "@omniroute/open-sse/services/antigravityProjectPersistence.ts"; import { @@ -891,43 +896,6 @@ function buildQuotaPreflightRateLimitedResult( lastErrorCode: 429, }; } -function quotaPreflightUnavailableUntil(resetAt?: string | null): string { - const resetMs = parseFutureDateMs(resetAt ?? null); - return new Date(resetMs ?? Date.now() + 5 * 60 * 1000).toISOString(); -} -async function markQuotaPreflightAccountUnavailable( - provider: string, - connectionId: string, - preflight: { quotaPercent?: number; resetAt?: string | null }, - requestedModel: string | null -): Promise { - const unavailableUntil = quotaPreflightUnavailableUntil(preflight.resetAt ?? null); - if (provider === "codex" && requestedModel?.trim()) { - await persistCodexChildCooldown({ - connectionId, - model: requestedModel, - rateLimitedUntil: unavailableUntil, - }); - return unavailableUntil; - } - - const percentLabel = Number.isFinite(preflight.quotaPercent) - ? `${Math.round((preflight.quotaPercent as number) * 100)}%` - : "exhausted"; - const modelLabel = requestedModel ? ` for ${requestedModel}` : ""; - - await updateProviderConnection(connectionId, { - rateLimitedUntil: unavailableUntil, - testStatus: "unavailable", - lastError: `Quota preflight blocked${modelLabel}: ${percentLabel}`, - lastErrorType: "quota_exhausted", - lastErrorSource: "quota_preflight", - errorCode: 429, - lastErrorAt: new Date().toISOString(), - }); - - return unavailableUntil; -} // Provider-scoped mutexes prevent race conditions during account selection without // serializing unrelated providers behind a single global lock. @@ -1326,6 +1294,7 @@ export async function getProviderCredentials( ); } } + rehydrateAntigravityFamilyLocksForConnections(provider, connections); // allowedConnections: restrict to specific connection IDs (from API key policy, #363) if (allowedConnections && allowedConnections.length > 0) { connections = connections.filter((conn) => allowedConnections.includes(conn.id)); @@ -2405,7 +2374,9 @@ export async function getProviderCredentialsWithQuotaPreflight( return defaultThresholdPercent; }; // #6842: openrouter also needs requestedModel, for the :free-window check. - const modelAwarePreflight = provider === "codex" || provider === "openrouter"; + // agy/antigravity need it so Claude weekly cannot cool a Gemini request. + const modelAwarePreflight = + provider === "codex" || provider === "openrouter" || isAntigravityQuotaProvider(provider); const preflightCredentials = requestedModel && modelAwarePreflight ? { ...credentials, requestedModel } : credentials; let preflight; @@ -2496,6 +2467,26 @@ export function isAgentrouterConnectionQuotaScope( ); } +async function resolveDailyResetForProvider( + provider: string | null, +): Promise<{ timezone?: unknown; hour?: unknown } | null> { + if (!provider) return null; + try { + const nodes = await getCachedProviderNodes(); + const node = nodes.find((candidate) => { + if (!candidate) return false; + return candidate.id === provider || candidate.prefix === provider; + }); + if (!node) return null; + return { + timezone: node.dailyQuotaResetTimezone, + hour: node.dailyQuotaResetHour, + }; + } catch { + return null; + } +} + /** * #10880 — cools down every connection sharing the failing connection's last * known egress IP. Best-effort and side-effect-safe by design: @@ -2712,7 +2703,10 @@ export async function markAccountUnavailable( model, provider, options.headers ?? null, - effectiveProviderProfile + effectiveProviderProfile, + null, + null, + await resolveDailyResetForProvider(provider), ); // T-PROBE: probe-origin failures (model test-all) must never remove the @@ -2967,6 +2961,7 @@ export async function markAccountUnavailable( "AUTH", `Model-only lockout for ${provider}:${model} — ${status} ${reason} ${Math.ceil(lockout.cooldownMs / 1000)}s (failureCount=${lockout.failureCount}, connection stays active)` ); + persistAntigravityFamilyCooldownIfQuota({ provider, connectionId, model, cooldownMs: lockout.cooldownMs, reason }); return { shouldFallback: true, cooldownMs: lockout.cooldownMs }; } const result = fallbackResult; diff --git a/src/sse/services/quotaPreflightUnavailable.ts b/src/sse/services/quotaPreflightUnavailable.ts new file mode 100644 index 0000000000..f1ac373a50 --- /dev/null +++ b/src/sse/services/quotaPreflightUnavailable.ts @@ -0,0 +1,61 @@ +import { persistCodexChildCooldown } from "@omniroute/open-sse/services/codexAccount/index.ts"; +import { persistAntigravityPreflightFamilyLock } from "@omniroute/open-sse/services/antigravityFamilyCooldown.ts"; +import { isAntigravityQuotaProvider } from "@omniroute/open-sse/services/antigravityQuotaFamily.ts"; +import { cooldownUntilMs } from "@omniroute/open-sse/services/accountFallback.ts"; +import { updateProviderConnection } from "@/lib/db/providers"; + +function parseFutureDateMs(value: string | null): number | null { + if (!value) return null; + const ms = cooldownUntilMs(value); + if (!Number.isFinite(ms) || ms <= Date.now()) return null; + return ms; +} + +function quotaPreflightUnavailableUntil(resetAt?: string | null): string { + const resetMs = parseFutureDateMs(resetAt ?? null); + return new Date(resetMs ?? Date.now() + 5 * 60 * 1000).toISOString(); +} + +export async function markQuotaPreflightAccountUnavailable( + provider: string, + connectionId: string, + preflight: { quotaPercent?: number; resetAt?: string | null }, + requestedModel: string | null +): Promise { + const unavailableUntil = quotaPreflightUnavailableUntil(preflight.resetAt ?? null); + if (provider === "codex" && requestedModel?.trim()) { + await persistCodexChildCooldown({ + connectionId, + model: requestedModel, + rateLimitedUntil: unavailableUntil, + }); + return unavailableUntil; + } + + if (isAntigravityQuotaProvider(provider) && requestedModel?.trim()) { + await persistAntigravityPreflightFamilyLock({ + provider, + connectionId, + model: requestedModel, + unavailableUntil, + }); + return unavailableUntil; + } + + const percentLabel = Number.isFinite(preflight.quotaPercent) + ? `${Math.round((preflight.quotaPercent as number) * 100)}%` + : "exhausted"; + const modelLabel = requestedModel ? ` for ${requestedModel}` : ""; + + await updateProviderConnection(connectionId, { + rateLimitedUntil: unavailableUntil, + testStatus: "unavailable", + lastError: `Quota preflight blocked${modelLabel}: ${percentLabel}`, + lastErrorType: "quota_exhausted", + lastErrorSource: "quota_preflight", + errorCode: 429, + lastErrorAt: new Date().toISOString(), + }); + + return unavailableUntil; +} diff --git a/stryker.conf.json b/stryker.conf.json index d13bf2c1d4..96f421f624 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -58,6 +58,7 @@ "tests/unit/account-fallback-retry-after-json.test.ts", "tests/unit/account-fallback-route-restriction-403.test.ts", "tests/unit/account-fallback-service.test.ts", + "tests/unit/moonshot-quota-writeback.test.ts", "tests/unit/accountfallback-ratelimit-400-4976.test.ts", "tests/unit/adaptive-admission-route-matrix.test.ts", "tests/unit/adaptive-admission-runtime.test.ts", @@ -67,6 +68,7 @@ "tests/unit/agentrouter-lock-scope-10334.test.ts", "tests/unit/alibaba-free-tier-exhaustion.test.ts", "tests/unit/anthropic-thinking-signature-recovery.test.ts", + "tests/unit/agy-family-not-connection-cooldown.test.ts", "tests/unit/antigravity-429-quota-cooldown.test.ts", "tests/unit/antigravity-429-quota-tdd.test.ts", "tests/unit/antigravity-prefer-stored-project.test.ts", @@ -90,6 +92,7 @@ "tests/unit/auth-opencode-zen-noauth-fallback.test.ts", "tests/unit/auth-passthrough-per-model-402-12242.test.ts", "tests/unit/auth-terminal-status.test.ts", + "tests/unit/authz/credential-export-always-protected.test.ts", "tests/unit/authz/discovery-routes-local-only.test.ts", "tests/unit/authz/oauth-autoimport-local-only.test.ts", "tests/unit/authz/route-guard-local-prefix.test.ts", diff --git a/tests/integration/freeModelBenchmarkShared.ts b/tests/integration/freeModelBenchmarkShared.ts index caf5cd8e78..e732c6e0b6 100644 --- a/tests/integration/freeModelBenchmarkShared.ts +++ b/tests/integration/freeModelBenchmarkShared.ts @@ -56,8 +56,8 @@ export const FREE_MODELS: FreeModelSpec[] = [ displayName: "Gemini 3.1 Flash-Lite", }, { provider: "gemini", model: "gemini/gemma-4-31b-it", displayName: "Gemma 4 31B (Gemini)" }, - { provider: "nvidia", model: "nvidia/openai/gpt-oss-20b", displayName: "GPT OSS 20B (NVIDIA)" }, - { provider: "nvidia", model: "nvidia/z-ai/glm-5.1", displayName: "GLM 5.1 (NVIDIA)" }, + { provider: "nvidia", model: "nvidia/openai/gpt-oss-120b", displayName: "GPT OSS 120B (NVIDIA)" }, + { provider: "nvidia", model: "nvidia/moonshotai/kimi-k3", displayName: "Kimi K3 (NVIDIA)" }, { provider: "nvidia", model: "nvidia/google/gemma-4-31b-it", diff --git a/tests/snapshots/executors/executor-map.json b/tests/snapshots/executors/executor-map.json index 94d70fb770..8f4956e814 100644 --- a/tests/snapshots/executors/executor-map.json +++ b/tests/snapshots/executors/executor-map.json @@ -135,6 +135,11 @@ "configSource": "", "provider": "cloudflare-playground" }, + "clova-studio": { + "className": "ClovaStudioExecutor", + "configSource": "clova-studio", + "provider": "clova-studio" + }, "cmd": { "className": "CommandCodeExecutor", "configSource": "", @@ -671,6 +676,6 @@ "provider": "zai-web" } }, - "keyCount": 134, + "keyCount": 135, "sharedInstances": [] } diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json index 43150e2b22..fbd4d1d22c 100644 --- a/tests/snapshots/provider/translate-path.json +++ b/tests/snapshots/provider/translate-path.json @@ -28,11 +28,11 @@ "apiKey": { "Accept": "text/event-stream", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -47,11 +47,11 @@ "nonStream": { "Accept": "application/json", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -66,11 +66,11 @@ "oauth": { "Accept": "text/event-stream", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", "X-Stainless-Arch": "", "X-Stainless-Lang": "js", "X-Stainless-OS": "MacOS", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -989,13 +989,13 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, cli)", + "User-Agent": "claude-cli/2.1.258 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", "X-Stainless-Os": "", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -1007,13 +1007,13 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, cli)", + "User-Agent": "claude-cli/2.1.258 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", "X-Stainless-Os": "", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -1026,13 +1026,13 @@ "Anthropic-Dangerous-Direct-Browser-Access": "true", "Anthropic-Version": "2023-06-01", "Content-Type": "application/json", - "User-Agent": "claude-cli/2.1.220 (external, cli)", + "User-Agent": "claude-cli/2.1.258 (external, cli)", "X-App": "cli", "X-Stainless-Arch": "", "X-Stainless-Helper-Method": "stream", "X-Stainless-Lang": "js", "X-Stainless-Os": "", - "X-Stainless-Package-Version": "0.94.0", + "X-Stainless-Package-Version": "0.112.1", "X-Stainless-Retry-Count": "0", "X-Stainless-Runtime": "node", "X-Stainless-Runtime-Version": "v26.3.0", @@ -1217,7 +1217,7 @@ } }, "clova-studio": { - "format": "openai", + "format": "clova", "headers": { "apiKey": { "Accept": "text/event-stream", @@ -1235,8 +1235,8 @@ } }, "url": { - "nonStream": "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions", - "stream": "https://clovastudio.stream.ntruss.com/v1/openai/chat/completions" + "nonStream": "https://clovastudio.stream.ntruss.com/v3/chat-completions", + "stream": "https://clovastudio.stream.ntruss.com/v3/chat-completions" } }, "codebuddy-cn": { @@ -5288,6 +5288,29 @@ "stream": "https://api.sea-lion.ai/v1/chat/completions" } }, + "seekai": { + "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://seekai.cc/v1/chat/completions", + "stream": "https://seekai.cc/v1/chat/completions" + } + }, "sensenova": { "format": "openai", "headers": { diff --git a/tests/unit/account-fallback-service.test.ts b/tests/unit/account-fallback-service.test.ts index d28075545a..d2693a5acf 100644 --- a/tests/unit/account-fallback-service.test.ts +++ b/tests/unit/account-fallback-service.test.ts @@ -2005,3 +2005,51 @@ test("#10460 acceptance: unambiguous model-unsupported 400 makes exactly ONE ups ); } }); + +const MOONSHOT_COMPAT = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; +const MOONSHOT_TPD = + "[429]: Your account org-x / proj-y request reached organization TPD rate limit, current: 1537190, limit: 1500000"; +const MOONSHOT_BROKE = + "[429]: Your account org-x is suspended due to insufficient balance, please recharge your account or check your plan and billing details"; + +test("checkFallbackError: compatible Moonshot insufficient balance is creditsExhausted", () => { + const result = checkFallbackError(429, MOONSHOT_BROKE, 0, null, MOONSHOT_COMPAT); + assert.equal(result.creditsExhausted, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +test("checkFallbackError: compatible node empty wallet without billing-suspend phrasing is creditsExhausted", () => { + const result = checkFallbackError( + 429, + "You have insufficient balance, please recharge your account", + 0, + null, + MOONSHOT_COMPAT, + ); + assert.equal(result.creditsExhausted, true); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +test("isDailyQuotaExhausted detects organization TPD rate limit", () => { + const { isDailyQuotaExhausted } = accountFallback; + assert.equal(isDailyQuotaExhausted(MOONSHOT_TPD), true); + assert.equal(isDailyQuotaExhausted("The engine is currently overloaded"), false); +}); + +test("checkFallbackError: TPD with node clock uses that instant, not host midnight", () => { + const now = Date.parse("2026-09-02T07:30:00Z"); + const result = checkFallbackError(429, MOONSHOT_TPD, 0, null, MOONSHOT_COMPAT, null, null, null, null, { + timezone: "Asia/Shanghai", + hour: 0, + nowMs: now, + }); + assert.equal(result.dailyQuotaExhausted, true); + assert.equal(result.cooldownMs, Date.parse("2026-09-02T16:00:00Z") - now); + assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED); +}); + +test("checkFallbackError: TPD without clock or header is NOT host-midnight lock", () => { + const result = checkFallbackError(429, MOONSHOT_TPD, 0, null, MOONSHOT_COMPAT); + assert.notEqual(result.dailyQuotaExhausted, true); + assert.ok(result.cooldownMs < 2 * 60 * 60 * 1000); +}); diff --git a/tests/unit/agy-family-not-connection-cooldown.test.ts b/tests/unit/agy-family-not-connection-cooldown.test.ts new file mode 100644 index 0000000000..9c4499e8cb --- /dev/null +++ b/tests/unit/agy-family-not-connection-cooldown.test.ts @@ -0,0 +1,258 @@ +/** + * Claude weekly exhaustion must not cool the whole agy/antigravity connection. + * Gemini on the same account stays routable; only family:claude is locked. + */ +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-agy-family-cd-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "agy-family-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const quotaPreflight = await import("../../open-sse/services/quotaPreflight.ts"); +const family = await import("../../open-sse/services/antigravityQuotaFamily.ts"); +const fallback = await import("../../open-sse/services/accountFallback.ts"); +const { markConnectionQuotaExhausted } = await import("../../open-sse/executors/antigravity.ts"); +const { quotaRemainingPercentFromQuota } = await import( + "../../open-sse/services/combo/comboPredicates.ts" +); + +const CLAUDE_RESET = "2026-09-06T17:38:10.000Z"; +const GEMINI_RESET = "2026-09-09T09:59:00.000Z"; + +function mixedWindows() { + return { + claude_gpt_weekly: { percentUsed: 1, resetAt: CLAUDE_RESET }, + gemini_weekly: { percentUsed: 0.022, resetAt: GEMINI_RESET }, + "gemini-3.1-flash-lite": { percentUsed: 0.1, resetAt: null }, + }; +} + +test.after(() => { + fallback.clearAllModelLockouts(); + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +test("selectAntigravityQuotaWindowNames keeps Claude weekly off a Gemini request", () => { + const names = family.selectAntigravityQuotaWindowNames(Object.keys(mixedWindows()), "gemini-3.1-flash-lite"); + assert.deepEqual(names.sort(), ["gemini-3.1-flash-lite", "gemini_weekly"].sort()); +}); + +test("preflightQuota proceeds on Gemini when only Claude weekly is exhausted", async () => { + quotaPreflight.registerQuotaFetcher("agy", async () => ({ + used: 0, + total: 0, + percentUsed: 1, + limitReached: true, + windows: mixedWindows(), + })); + + const result = await quotaPreflight.preflightQuota("agy", "conn-1", { + requestedModel: "agy/gemini-3.1-flash-lite", + }); + assert.equal(result.proceed, true, "Gemini must not inherit Claude weekly exhaustion"); +}); + +test("preflightQuota blocks Gemini when gemini_weekly is exhausted", async () => { + quotaPreflight.registerQuotaFetcher("agy-gemini-dead", async () => ({ + used: 0, + total: 0, + percentUsed: 0.99, + windows: { + claude_gpt_weekly: { percentUsed: 0.1, resetAt: CLAUDE_RESET }, + gemini_weekly: { percentUsed: 0.99, resetAt: GEMINI_RESET }, + }, + })); + + const result = await quotaPreflight.preflightQuota("agy-gemini-dead", "conn-2", { + requestedModel: "gemini-3.1-flash-lite", + }); + assert.equal(result.proceed, false); + assert.equal(result.windowName, "gemini_weekly"); + assert.equal(result.resetAt, GEMINI_RESET); +}); + +test("evaluateQuotaCutoff with requestedModel ignores the other family window", () => { + const quota = { + used: 0, + total: 0, + percentUsed: 1, + limitReached: true, + windows: mixedWindows(), + }; + const gemini = quotaPreflight.evaluateQuotaCutoff(quota, undefined, { + provider: "antigravity", + requestedModel: "gemini-3.1-flash-lite", + }); + assert.equal(gemini.proceed, true); + + const claude = quotaPreflight.evaluateQuotaCutoff(quota, undefined, { + provider: "agy", + requestedModel: "claude-opus-4-6-thinking", + }); + assert.equal(claude.proceed, false); + assert.equal(claude.windowName, "claude_gpt_weekly"); +}); + +test("quotaRemainingPercentFromQuota for Gemini uses Gemini windows, not Claude", () => { + const quota = { windows: mixedWindows(), percentUsed: 1, limitReached: true }; + const remaining = quotaRemainingPercentFromQuota(quota, { + provider: "agy", + requestedModel: "gemini-3.1-flash-lite", + }); + assert.ok(remaining > 50, `expected Gemini remaining, got ${remaining}`); +}); + +test("markConnectionQuotaExhausted with a Gemini model locks the family, not the row", async () => { + fallback.clearAllModelLockouts(); + const conn = await providersDb.createProviderConnection({ + provider: "agy", + authType: "oauth", + name: "agy-family-gemini", + }); + const connId = (conn as { id: string }).id; + + markConnectionQuotaExhausted(connId, 24 * 60 * 60 * 1000, "gemini-3.1-flash-lite"); + + assert.equal( + providersDb.isConnectionRateLimited(connId), + false, + "connection row must stay selectable for the other family" + ); + assert.equal(fallback.isModelLocked("agy", connId, "gemini-3.1-flash-lite"), true); + assert.equal(fallback.isModelLocked("agy", connId, "gemini-3.7-flash-high"), true); + assert.equal(fallback.isModelLocked("agy", connId, "claude-opus-4-6-thinking"), false); +}); + +test("Antigravity RPM 429 stays exact-model and does not persist a family cooldown", async () => { + fallback.clearAllModelLockouts(); + const auth = await import("../../src/sse/services/auth.ts"); + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + email: "rpm@example.test", + accessToken: "tok-rpm", + isActive: true, + testStatus: "active", + }); + const connId = (conn as { id: string }).id; + + await auth.markAccountUnavailable( + connId, + 429, + "RESOURCE_EXHAUSTED: Resource has been exhausted (requests per minute / RPM limit was reached)", + "antigravity", + "gemini-3-pro" + ); + + const sibling = await auth.getProviderCredentials("antigravity", null, null, "gemini-2.5-pro"); + assert.ok(sibling && !("allRateLimited" in sibling && sibling.allRateLimited)); + assert.equal(sibling.connectionId, connId); + + const fresh = await providersDb.getProviderConnectionById(connId); + const psd = (fresh as { providerSpecificData?: Record }).providerSpecificData; + assert.equal( + psd && typeof psd === "object" ? psd.antigravityFamilyRateLimitedUntil : undefined, + undefined + ); + await providersDb.updateProviderConnection(connId, { isActive: false }); +}); + +test("Antigravity QPM 429 stays exact-model and does not persist a family cooldown", async () => { + fallback.clearAllModelLockouts(); + const auth = await import("../../src/sse/services/auth.ts"); + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + email: "qpm@example.test", + accessToken: "tok-qpm", + isActive: true, + testStatus: "active", + }); + const connId = (conn as { id: string }).id; + + await auth.markAccountUnavailable( + connId, + 429, + "RESOURCE_EXHAUSTED: Resource has been exhausted (queries per minute limit was reached)", + "antigravity", + "gemini-3-pro" + ); + + const sibling = await auth.getProviderCredentials("antigravity", null, null, "gemini-2.5-pro"); + assert.ok(sibling && !("allRateLimited" in sibling && sibling.allRateLimited)); + assert.equal(sibling.connectionId, connId); + + const fresh = await providersDb.getProviderConnectionById(connId); + const psd = (fresh as { providerSpecificData?: Record }).providerSpecificData; + assert.equal( + psd && typeof psd === "object" ? psd.antigravityFamilyRateLimitedUntil : undefined, + undefined + ); + await providersDb.updateProviderConnection(connId, { isActive: false }); +}); + +test("persisted family cooldown rehydrates after a process-local lockout wipe", async () => { + fallback.clearAllModelLockouts(); + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "ag-family-persist", + }); + const connId = (conn as { id: string }).id; + const until = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + const { persistAntigravityFamilyCooldown, rehydrateAntigravityFamilyLocks } = await import( + "../../open-sse/services/antigravityFamilyCooldown.ts" + ); + await persistAntigravityFamilyCooldown({ + connectionId: connId, + model: "claude-sonnet-4", + rateLimitedUntil: until, + }); + + fallback.clearAllModelLockouts(); + assert.equal(fallback.isModelLocked("antigravity", connId, "claude-opus-4"), false); + + const fresh = await providersDb.getProviderConnectionById(connId); + rehydrateAntigravityFamilyLocks( + "antigravity", + connId, + (fresh as { providerSpecificData?: Record }).providerSpecificData + ); + assert.equal(fallback.isModelLocked("antigravity", connId, "claude-opus-4"), true); + assert.equal(fallback.isModelLocked("antigravity", connId, "gemini-3.1-flash-lite"), false); + assert.equal(providersDb.isConnectionRateLimited(connId), false); +}); + +test("preflight family lock covers both agy and antigravity spellings", async () => { + fallback.clearAllModelLockouts(); + const { persistAntigravityPreflightFamilyLock } = await import( + "../../open-sse/services/antigravityFamilyCooldown.ts" + ); + const conn = await providersDb.createProviderConnection({ + provider: "antigravity", + authType: "oauth", + name: "ag-preflight-alias", + }); + const connId = (conn as { id: string }).id; + const until = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + await persistAntigravityPreflightFamilyLock({ + provider: "antigravity", + connectionId: connId, + model: "claude-sonnet-4", + unavailableUntil: until, + }); + + assert.equal(fallback.isModelLocked("antigravity", connId, "claude-opus-4"), true); + assert.equal(fallback.isModelLocked("agy", connId, "claude-opus-4"), true); + assert.equal(fallback.isModelLocked("antigravity", connId, "gemini-3.1-flash-lite"), false); + assert.equal(fallback.isModelLocked("agy", connId, "gemini-3.1-flash-lite"), false); +}); diff --git a/tests/unit/anthropic-cache-fingerprint.test.ts b/tests/unit/anthropic-cache-fingerprint.test.ts index ebdb9690af..59c736a63c 100644 --- a/tests/unit/anthropic-cache-fingerprint.test.ts +++ b/tests/unit/anthropic-cache-fingerprint.test.ts @@ -5,6 +5,6 @@ import { CLAUDE_CODE_CLIENT_BILLING_VERSION } from "../../src/shared/constants/c describe("Anthropic billing header fingerprint (#1638)", () => { it("uses the immutable build revision captured from the signed CLI", () => { - assert.equal(CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.220.1f2"); + assert.equal(CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.258.1e2"); }); }); diff --git a/tests/unit/antigravity-429-quota-cooldown.test.ts b/tests/unit/antigravity-429-quota-cooldown.test.ts index 0191391d8a..eccdc8942d 100644 --- a/tests/unit/antigravity-429-quota-cooldown.test.ts +++ b/tests/unit/antigravity-429-quota-cooldown.test.ts @@ -169,6 +169,11 @@ test("direct Antigravity has one downstream model-lock owner and clamps body pro /accountSemaphoreKey && !deferAntigravityQuotaStateToCaller/, "chatCore must not apply a prose-derived Antigravity semaphore TTL" ); + assert.match( + chatCoreSource, + /Dropped generic quota cache after 429/, + "non-Codex 429 must leave a QUOTA debug breadcrumb" + ); assert.match( chatCoreSource, /if \(deferAntigravityQuotaStateToCaller\)[\s\S]{0,2000}else if \(kimiRateLimitResetAt\)/ diff --git a/tests/unit/api-key-self-service.test.ts b/tests/unit/api-key-self-service.test.ts index 6b768f7950..242c3b22b3 100644 --- a/tests/unit/api-key-self-service.test.ts +++ b/tests/unit/api-key-self-service.test.ts @@ -435,3 +435,39 @@ test("self-service status normalizes Codex account quota for one explicit connec }, }); }); + +test("self-service fetches Moonshot custom-node quota via providerSpecificData host", async () => { + const metadata = { + id: "key-mnative", + name: "moonshot native", + scopes: [SELF_USAGE_SCOPE, SELF_ACCOUNT_QUOTA_SCOPE], + allowedConnections: ["conn-mnative"], + }; + const fetches: string[] = []; + const { deps } = makeDeps({ + getProviderConnectionById: async (connectionId: string) => ({ + id: connectionId, + provider: "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf", + isActive: true, + providerSpecificData: { baseUrl: "https://api.moonshot.cn/v1" }, + }), + fetchAndPersistProviderLimits: async (connectionId: string) => { + fetches.push(connectionId); + return { + connection: { id: connectionId, provider: "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf" }, + usage: { + plan: "Kimi 开放平台(国内)", + quotas: { + available: { remaining: 15, remainingPercentage: 100, unlimited: true, currency: "CNY" }, + }, + }, + cache: { quotas: null, plan: null, message: null, fetchedAt: "" }, + }; + }, + }); + + const status = await buildApiKeySelfServiceStatus(metadata, deps); + assert.deepEqual(fetches, ["conn-mnative"]); + assert.equal(status.accountQuotas[0].unavailable, undefined); + assert.equal(status.accountQuotas[0].plan, "Kimi 开放平台(国内)"); +}); diff --git a/tests/unit/authz/credential-export-always-protected.test.ts b/tests/unit/authz/credential-export-always-protected.test.ts new file mode 100644 index 0000000000..c297bad003 --- /dev/null +++ b/tests/unit/authz/credential-export-always-protected.test.ts @@ -0,0 +1,132 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + isAlwaysProtectedPath, + isLocalOnlyPath, + ALWAYS_PROTECTED_API_PATHS, +} from "../../../src/server/authz/routeGuard.ts"; + +// GHSA-5926-2w35-7h4q — the Claude/Codex OAuth export routes gate on +// `requireManagementAuth(request)` with no `alwaysRequireAuth`, which fails open +// under requireLogin=false, and neither path was in ALWAYS_PROTECTED_API_PATHS. +// An unauthenticated caller who knows a connection id could download the +// operator's raw access_token / refresh_token / id_token. +// +// This is the THIRD recurrence of one class: GHSA-mghq-58h3-qcqj added +// /api/db-backups, GHSA-v7g9-7f55-5g46 added the /api/settings/*-json siblings +// it had missed, and this one is the siblings BOTH missed. So the test is +// written as an inventory of the whole class rather than two more assertions: +// a route that hands out stored credentials, dumps captured traffic, or writes +// the operator's CLI config must be hard-gated (ALWAYS_PROTECTED or +// LOCAL_ONLY), never left on the fail-open MANAGEMENT tier. + +const HARD_GATED_INVENTORY: ReadonlyArray<{ path: string; why: string }> = [ + // ── Reported in GHSA-5926-2w35-7h4q ────────────────────────────────────── + { + path: "/api/providers/6f3c1b7e-0000-4000-8000-000000000000/claude-auth/export", + why: "returns the connection's raw Claude OAuth access_token/refresh_token", + }, + { + path: "/api/providers/6f3c1b7e-0000-4000-8000-000000000000/codex-auth/export", + why: "returns the connection's raw Codex access_token/refresh_token/id_token", + }, + // ── Found sweeping the class while fixing the above ────────────────────── + { + path: "/api/logs/export", + why: "dumps call_logs (prompts and responses) and proxy_logs for up to 168h", + }, + { + path: "/api/cli-tools/codex-profiles", + why: "PUT writes attacker-supplied auth.json and config.toml into the operator's Codex CLI config", + }, + // ── Same family: WRITE the operator's credentials into host CLI files ─── + // These do not hand the credential to the caller, so they are a step below + // the export routes — but anonymous is still the wrong audience for "write + // this connection's token into ~/.codex/auth.json". ALWAYS_PROTECTED rather + // than LOCAL_ONLY on purpose: it closes the anonymous hole without breaking + // an operator driving the dashboard through a tunnel. + { + path: "/api/providers/6f3c1b7e-0000-4000-8000-000000000000/codex-auth/apply-local", + why: "writes the connection's credential into the host's ~/.codex/auth.json", + }, + { + path: "/api/providers/6f3c1b7e-0000-4000-8000-000000000000/claude-auth/apply-local", + why: "writes the connection's credential into the host's Claude CLI config", + }, + { + path: "/api/providers/agy-auth/apply-local", + why: "writes into ~/.gemini/antigravity-cli/antigravity-oauth-token", + }, + // ── Already fixed; pinned so a refactor cannot silently drop them ──────── + { path: "/api/db-backups/export", why: "GHSA-mghq-58h3-qcqj" }, + { path: "/api/db-backups/exportAll", why: "GHSA-mghq-58h3-qcqj" }, + { path: "/api/settings/export-json", why: "GHSA-v7g9-7f55-5g46" }, + { path: "/api/settings/import-json", why: "GHSA-v7g9-7f55-5g46" }, + { path: "/api/settings/database", why: "irreversible database replace" }, + { path: "/api/shutdown", why: "stops the server" }, + // ── Hard-gated by the LOCAL_ONLY tier instead ──────────────────────────── + { + path: "/api/tools/traffic-inspector/export.har", + why: "captured traffic can contain Authorization headers (LOCAL_ONLY)", + }, + { + path: "/api/tools/traffic-inspector/sessions/abc/export.har", + why: "same, per session (LOCAL_ONLY)", + }, +]; + +test("every credential/traffic export and CLI-config write is hard-gated", () => { + for (const { path, why } of HARD_GATED_INVENTORY) { + const gated = isAlwaysProtectedPath(path) || isLocalOnlyPath(path); + assert.ok( + gated, + `${path} is on the fail-open MANAGEMENT tier — anonymous under requireLogin=false. ${why}` + ); + } +}); + +test("the trailing-slash spelling is gated too", () => { + for (const path of [ + "/api/providers/abc/claude-auth/export/", + "/api/providers/abc/codex-auth/export/", + "/api/logs/export/", + "/api/cli-tools/codex-profiles/", + ]) { + assert.ok(isAlwaysProtectedPath(path) || isLocalOnlyPath(path), path); + } +}); + +test("the new patterns do not over-protect their neighbours", () => { + // The dynamic-segment entries must not swallow the rest of /api/providers/, + // which is ordinary MANAGEMENT and has to keep working under requireLogin=false. + for (const path of [ + "/api/providers", + "/api/providers/abc", + "/api/providers/abc/models", + "/api/providers/abc/claude-auth", + "/api/providers/abc/codex-auth", + "/api/providers/abc/claude-auth/apply", + "/api/providers/agy-auth", + "/api/logs", + "/api/cli-tools", + ]) { + assert.equal( + isAlwaysProtectedPath(path), + false, + `${path} must stay on the MANAGEMENT tier — hard-gating it breaks keyless local-first installs` + ); + } +}); + +test("a connection id cannot escape the pattern with a slash", () => { + // `[^/]+` is deliberate: a traversal-ish id must not match and silently drop + // back to the fail-open tier by looking like a different route. + assert.equal(isAlwaysProtectedPath("/api/providers/a/b/claude-auth/export"), false); +}); + +test("the plain-path allowlist keeps its existing entries", () => { + for (const p of ["/api/shutdown", "/api/settings/database", "/api/db-backups"]) { + assert.ok(ALWAYS_PROTECTED_API_PATHS.includes(p), p); + } +}); diff --git a/tests/unit/build/check-licenses.test.ts b/tests/unit/build/check-licenses.test.ts index 746f9cc9dc..8fb104ef9a 100644 --- a/tests/unit/build/check-licenses.test.ts +++ b/tests/unit/build/check-licenses.test.ts @@ -330,3 +330,19 @@ test("integration: classifyLicense denies AGPL-3.0 against real allowlist", () = const result = classifyLicense("hypothetical-agpl@1.0.0", "AGPL-3.0", allowlist); assert.equal(result.status, "denied"); }); + +test("integration: @eloqnt/* UNKNOWN licenses are documented exceptions (next-intl transitive)", () => { + const allowlist = loadAllowlist(); + for (const pkg of [ + "@eloqnt/config@0.0.2", + "@eloqnt/format-json@0.0.3", + "@eloqnt/format-po@0.0.3", + ]) { + const result = classifyLicense(pkg, "UNKNOWN", allowlist); + assert.equal( + result.status, + "exception", + `${pkg} ships no license field; must be a documented exception, not allowed/denied` + ); + } +}); diff --git a/tests/unit/catalog-updates-v3x.test.ts b/tests/unit/catalog-updates-v3x.test.ts index e95132bec6..e233380490 100644 --- a/tests/unit/catalog-updates-v3x.test.ts +++ b/tests/unit/catalog-updates-v3x.test.ts @@ -23,18 +23,21 @@ test("Pollinations catalog mirrors the current public text model lineup", () => ); }); -test("NVIDIA catalog includes the verified 2026 additions and GPT OSS 20B alias resolution", () => { +test("NVIDIA catalog includes the current hosted models and GPT OSS 120B alias resolution", () => { const ids = new Set(getModelsByProviderId("nvidia").map((model) => model.id)); - assert.ok(ids.has("openai/gpt-oss-20b")); + assert.ok(ids.has("moonshotai/kimi-k3")); + assert.ok(ids.has("deepseek-ai/deepseek-v4-pro-0813")); + assert.ok(ids.has("deepseek-ai/deepseek-v4-flash-0731")); + assert.ok(ids.has("nvidia/nemotron-3.5-lightning-30b-a3b")); + assert.ok(ids.has("meta/muse-glimmer-30b")); + assert.ok(ids.has("google/diffusiongemma-26b-a4b-it")); + assert.ok(ids.has("openai/gpt-oss-120b")); assert.ok(ids.has("nvidia/nemotron-3-super-120b-a12b")); - assert.ok(ids.has("mistralai/mistral-large-3-675b-instruct-2512")); - assert.ok(ids.has("qwen/qwen3.5-397b-a17b")); - assert.ok(ids.has("mistralai/devstral-2-123b-instruct-2512")); - assert.deepEqual(resolveCanonicalProviderModel("nvidia", "gpt-oss-20b"), { + assert.deepEqual(resolveCanonicalProviderModel("nvidia", "gpt-oss-120b"), { provider: "nvidia", - model: "openai/gpt-oss-20b", + model: "openai/gpt-oss-120b", }); }); diff --git a/tests/unit/cc-bridge-transforms.test.ts b/tests/unit/cc-bridge-transforms.test.ts index ba915dcaca..cbaf19c50a 100644 --- a/tests/unit/cc-bridge-transforms.test.ts +++ b/tests/unit/cc-bridge-transforms.test.ts @@ -52,7 +52,7 @@ test("DEFAULT_CC_BRIDGE_PIPELINE places billing header at [0] and identity at [1 DEFAULT_CC_BRIDGE_PIPELINE ); const blocks = result.body.system as any[]; - assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header: cc_version=2.1.220.1f2;")); + assert.ok(blocks[0].text.startsWith("x-anthropic-billing-header: cc_version=2.1.258.1e2;")); assert.equal(blocks[1].text, CLAUDE_AGENT_SDK_IDENTITY); }); diff --git a/tests/unit/chatcore-attempt-logging.test.ts b/tests/unit/chatcore-attempt-logging.test.ts index b9fe57fbd3..2af6bbd41c 100644 --- a/tests/unit/chatcore-attempt-logging.test.ts +++ b/tests/unit/chatcore-attempt-logging.test.ts @@ -16,6 +16,7 @@ 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 { getAuditLog } = await import("../../src/lib/compliance/index.ts"); type CodexRotationEnvelope = { _omniroute?: { @@ -136,3 +137,61 @@ test("connectionId falls back to credentials.connectionId when null, and error i assert.equal(row.status, 502); assert.match(String(row.error ?? ""), /upstream boom/); }); + +function duplicateHeartbeatBody() { + return { + choices: [ + { + message: { + tool_calls: [ + { function: { name: "heartbeat_respond", arguments: "{}" } }, + { function: { name: "heartbeat_respond", arguments: "{}" } }, + ], + }, + }, + ], + }; +} + +test("duplicate tool_calls in the assembled body writes provider.spec_violation audit", () => { + persistAttemptLogs( + { status: 200, responseBody: duplicateHeartbeatBody() }, + baseCtx({ pendingRequestId: "attempt-spec-violation-1", skillRequestId: "skill-spec-1" }) + ); + // logAuditEvent is synchronous; do not wait on the fire-and-forget saveCallLog. + const rows = getAuditLog({ action: "provider.spec_violation", requestId: "skill-spec-1" }); + assert.equal(rows.length, 1); + assert.equal(rows[0]?.resourceType, "provider_spec_violation"); + const details = rows[0]?.details; + assert.ok(details && typeof details === "object"); + assert.equal( + (details as { violation?: string }).violation, + 'duplicate tool_calls entry for "heartbeat_respond"' + ); +}); + +test("unique tool_calls do not write provider.spec_violation audit", () => { + persistAttemptLogs( + { + status: 200, + responseBody: { + choices: [ + { + message: { + tool_calls: [ + { function: { name: "heartbeat_respond", arguments: "{}" } }, + { function: { name: "other_tool", arguments: "{}" } }, + ], + }, + }, + ], + }, + }, + baseCtx({ pendingRequestId: "attempt-spec-clean-1", skillRequestId: "skill-spec-clean-1" }) + ); + const rows = getAuditLog({ + action: "provider.spec_violation", + requestId: "skill-spec-clean-1", + }); + assert.equal(rows.length, 0); +}); diff --git a/tests/unit/check-public-creds.test.ts b/tests/unit/check-public-creds.test.ts index 5531c5d336..b726218495 100644 --- a/tests/unit/check-public-creds.test.ts +++ b/tests/unit/check-public-creds.test.ts @@ -68,7 +68,10 @@ test("allowlist freezes a literal by file:line:value key", () => { }); test("allowlist preserves the local ZCode handshake client ID without weakening credential detection", () => { - const src = `${"\n".repeat(301)}clientId: \`omniroute-\${process.pid}\`,`; + // 312 newlines puts the statement on line 313, which is where it lives in + // zcodeProtocol.ts today. The allowlist key carries the line number, so this + // literal has to be kept in step with the source (it moved 302 -> 313). + const src = `${"\n".repeat(312)}clientId: \`omniroute-\${process.pid}\`,`; assert.deepEqual( findLiteralCreds(src, KNOWN_LITERAL_CREDS, "open-sse/executors/zcodeProtocol.ts"), [] diff --git a/tests/unit/classify429.test.ts b/tests/unit/classify429.test.ts index d75d7f648f..6d42875afe 100644 --- a/tests/unit/classify429.test.ts +++ b/tests/unit/classify429.test.ts @@ -443,3 +443,18 @@ test("classify429: retryDelay outside a RetryInfo detail is ignored", () => { }; assert.equal(classify429({ status: 429, body }), "quota_exhausted"); }); + +const MOONSHOT_TPD = + "Your account org-73b383ab6d45484eb2ef72161074495c / proj-30863601b47548bdbbabc42ff4c72eee request reached organization TPD rate limit, current: 1537190, limit: 1500000"; + +test("classify429: Moonshot organization TPD rate limit is quota_exhausted", () => { + assert.equal(classify429({ status: 429, body: MOONSHOT_TPD }), "quota_exhausted"); + assert.equal(looksLikeQuotaExhausted(MOONSHOT_TPD), true); +}); + +test("classify429: Moonshot engine overloaded stays rate_limit", () => { + assert.equal( + classify429({ status: 429, body: "The engine is currently overloaded, please try again later" }), + "rate_limit", + ); +}); diff --git a/tests/unit/claude-codex-identity-version-sync.test.ts b/tests/unit/claude-codex-identity-version-sync.test.ts index d274bb9f34..cda482fba9 100644 --- a/tests/unit/claude-codex-identity-version-sync.test.ts +++ b/tests/unit/claude-codex-identity-version-sync.test.ts @@ -38,11 +38,11 @@ test("Claude CLI version constants are in lockstep across all 4 sources", () => ); }); -test("Claude CLI wire versions match the captured 2.1.220 binary", () => { - assert.equal(canonical.CLAUDE_CODE_CLIENT_VERSION, "2.1.220"); - assert.equal(canonical.CLAUDE_CODE_CLIENT_BUILD_REVISION, "1f2"); - assert.equal(canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.220.1f2"); - assert.equal(canonical.CLAUDE_CODE_SDK_PACKAGE_VERSION, "0.94.0"); +test("Claude CLI wire versions match the captured 2.1.258 binary", () => { + assert.equal(canonical.CLAUDE_CODE_CLIENT_VERSION, "2.1.258"); + assert.equal(canonical.CLAUDE_CODE_CLIENT_BUILD_REVISION, "1e2"); + assert.equal(canonical.CLAUDE_CODE_CLIENT_BILLING_VERSION, "2.1.258.1e2"); + assert.equal(canonical.CLAUDE_CODE_SDK_PACKAGE_VERSION, "0.112.1"); assert.equal(canonical.CLAUDE_CODE_RUNTIME_VERSION, "v26.3.0"); assert.equal( compat.CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION, diff --git a/tests/unit/claude-fable-5-1.test.ts b/tests/unit/claude-fable-5-1.test.ts new file mode 100644 index 0000000000..b494db3d3a --- /dev/null +++ b/tests/unit/claude-fable-5-1.test.ts @@ -0,0 +1,172 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + getModelTargetFormat, + getModelsByProviderId, + supportsClaudeMaxEffort, + supportsXHighEffort, +} from "../../open-sse/config/providerModels.ts"; +import { getUnsupportedParams } from "../../open-sse/config/providerRegistry.ts"; +import { modelHasNativeContext1m } from "../../open-sse/config/claudeCodeCompatibleIdentity.ts"; +import { modelSupportsContext1mBeta } from "../../open-sse/config/context1m.ts"; +import { normalizeClaudeAdaptiveThinking } from "../../open-sse/services/claudeAdaptiveThinking.ts"; +import { getNextFamilyFallback } from "../../open-sse/services/modelFamilyFallback.ts"; +import { getModelPricing } from "../../open-sse/services/providerCostData.ts"; +import { getStaticModelsForProvider } from "../../src/lib/providers/staticModels.ts"; +import { getDefaultPricing } from "../../src/shared/constants/pricing.ts"; +import { + getModelSpec, + normalizeForcedToolChoiceForModel, + normalizeThinkingForModel, +} from "../../src/shared/constants/modelSpecs.ts"; + +const MODEL_ID = "claude-fable-5-1"; +const BEDROCK_MODEL_ID = "anthropic.claude-fable-5-1"; +const EFFORTS = ["low", "medium", "high", "xhigh", "max"]; + +test("Claude Fable 5.1 is registered only on verified launch surfaces", () => { + for (const [providerId, modelId] of [ + ["anthropic", MODEL_ID], + ["claude", MODEL_ID], + ["claude-web", MODEL_ID], + ["bedrock", BEDROCK_MODEL_ID], + ["vertex", MODEL_ID], + ["vertex-partner", MODEL_ID], + ] as const) { + const model = getModelsByProviderId(providerId).find((entry) => entry.id === modelId); + assert.ok(model, `${providerId} must expose ${modelId}`); + if (providerId === "vertex" || providerId === "vertex-partner") { + assert.equal(model.targetFormat, "claude", `${providerId} wire format`); + } else { + assert.equal(model.contextLength, 1_000_000, `${providerId} context window`); + assert.equal(model.maxOutputTokens, 128_000, `${providerId} max output`); + assert.deepEqual(model.supportedThinkingEfforts, EFFORTS, `${providerId} effort levels`); + } + } + + assert.equal(getModelTargetFormat("vertex", MODEL_ID), "claude"); + assert.equal(getModelTargetFormat("vertex-partner", MODEL_ID), "claude"); + + for (const providerId of ["github", "ghe-copilot", "kiro"]) { + const ids = new Set(getModelsByProviderId(providerId).map((entry) => entry.id)); + assert.equal(ids.has(MODEL_ID), false, `${providerId} availability is not verified`); + } + + const cursorModels = new Map( + getModelsByProviderId("cursor").map((entry) => [entry.id, entry] as const) + ); + assert.equal(cursorModels.has(MODEL_ID), false, "cursor exposes only selectable variants"); + + const cursorApiIds = new Set(getModelsByProviderId("cursor-api").map((entry) => entry.id)); + assert.equal(cursorApiIds.has(MODEL_ID), false); + for (const effort of EFFORTS) { + const cursorId = `${MODEL_ID}-thinking-${effort}`; + const cursor1mId = `${cursorId}-1m`; + assert.equal(cursorModels.has(`${MODEL_ID}-${effort}`), false); + assert.equal(cursorModels.get(cursorId)?.contextLength, 300_000, cursorId); + assert.equal(cursorModels.get(cursorId)?.maxOutputTokens, 128_000, cursorId); + assert.equal(cursorApiIds.has(cursorId), true, `cursor-api must expose ${cursorId}`); + assert.equal(cursorModels.get(cursor1mId)?.contextLength, 1_000_000, cursor1mId); + assert.equal(cursorModels.get(cursor1mId)?.maxOutputTokens, 128_000, cursor1mId); + assert.equal(cursorApiIds.has(cursor1mId), true, `cursor-api must expose ${cursor1mId}`); + } + + assert.ok( + getStaticModelsForProvider("claude")?.some((entry) => entry.id === MODEL_ID), + "Claude OAuth static discovery must expose Fable 5.1" + ); + assert.equal( + getNextFamilyFallback(`claude/${MODEL_ID}`, new Set([`claude/${MODEL_ID}`])), + "claude/claude-fable-5" + ); +}); + +test("Claude Fable 5.1 has native 1M context and adaptive-only thinking", () => { + assert.equal(modelHasNativeContext1m(MODEL_ID), true); + assert.equal(modelHasNativeContext1m(BEDROCK_MODEL_ID), true); + assert.equal(modelSupportsContext1mBeta(MODEL_ID), false); + + const spec = getModelSpec(MODEL_ID); + assert.equal(spec?.contextWindow, 1_000_000); + assert.equal(spec?.maxOutputTokens, 128_000); + assert.equal(spec?.supportsThinking, true); + assert.equal(spec?.supportsTools, true); + assert.equal(spec?.supportsVision, true); + assert.equal(spec?.adaptiveThinkingOnly, true); + assert.equal(spec?.rejectsThinkingDisabled, true); + assert.equal( + (spec as typeof spec & { rejectsForcedToolChoice?: boolean })?.rejectsForcedToolChoice, + true + ); + + assert.equal(getModelSpec(`global.${BEDROCK_MODEL_ID}`), spec); + assert.equal(supportsXHighEffort("claude", MODEL_ID), true); + assert.equal(supportsClaudeMaxEffort(MODEL_ID), true); +}); + +test("Claude Fable 5.1 strips unsupported sampling parameters", () => { + for (const providerId of ["anthropic", "claude"] as const) { + const unsupported = getUnsupportedParams(providerId, MODEL_ID); + for (const param of ["temperature", "top_p", "top_k"]) { + assert.ok(unsupported.includes(param), `${providerId}/${MODEL_ID} must strip ${param}`); + } + } +}); + +test("Claude Fable 5.1 normalizes disabled and manual thinking to adaptive", () => { + const withoutDisabled = normalizeThinkingForModel( + { model: MODEL_ID, thinking: { type: "disabled" }, marker: true }, + MODEL_ID + ); + assert.equal("thinking" in withoutDisabled, false); + assert.equal(withoutDisabled.marker, true); + + const adaptive = normalizeClaudeAdaptiveThinking( + { model: MODEL_ID, thinking: { type: "enabled", budget_tokens: 64_000 } }, + MODEL_ID + ); + assert.deepEqual(adaptive.thinking, { type: "adaptive" }); +}); + +test("Claude Fable 5.1 relaxes forced tool choices without removing tools", () => { + for (const toolChoice of [ + "required", + "any", + { type: "any" }, + { type: "tool", name: "read_file" }, + { type: "function", function: { name: "read_file" } }, + ]) { + const tools = [{ name: "read_file", input_schema: { type: "object" } }]; + const result = normalizeForcedToolChoiceForModel( + { model: MODEL_ID, tools, tool_choice: toolChoice, marker: true }, + MODEL_ID + ); + assert.equal("tool_choice" in result, false); + assert.equal(result.tools, tools); + assert.equal(result.marker, true); + } + + const auto = { model: MODEL_ID, tool_choice: { type: "auto" } }; + assert.equal(normalizeForcedToolChoiceForModel(auto, MODEL_ID), auto); + + const older = { model: "claude-fable-5", tool_choice: { type: "tool", name: "read_file" } }; + assert.equal(normalizeForcedToolChoiceForModel(older, "claude-fable-5"), older); +}); + +test("Claude Fable 5.1 pricing matches Anthropic's published rates", () => { + for (const providerId of ["anthropic", "cc"] as const) { + const price = getDefaultPricing()[providerId][MODEL_ID]; + assert.equal(price.input, 10); + assert.equal(price.output, 50); + assert.equal(price.cached, 0.25); + assert.equal(price.reasoning, 50); + assert.equal(price.cache_creation, 12.5); + } + + assert.deepEqual(getModelPricing("anthropic", MODEL_ID), { + inputCostPer1M: 10, + outputCostPer1M: 50, + isFree: false, + }); +}); diff --git a/tests/unit/claude-web-sonnet5-registry-6209.test.ts b/tests/unit/claude-web-sonnet5-registry-6209.test.ts index 9930a04ae2..a2f568fc9c 100644 --- a/tests/unit/claude-web-sonnet5-registry-6209.test.ts +++ b/tests/unit/claude-web-sonnet5-registry-6209.test.ts @@ -9,6 +9,7 @@ test("claude-web registry matches the current selectable model set", () => { assert.deepEqual( ids, [ + "claude-fable-5-1", "claude-fable-5", "claude-haiku-4-5-20251001", "claude-opus-5", diff --git a/tests/unit/client-identity-profiles.test.ts b/tests/unit/client-identity-profiles.test.ts index 54e00736a5..6c24c54eaa 100644 --- a/tests/unit/client-identity-profiles.test.ts +++ b/tests/unit/client-identity-profiles.test.ts @@ -39,7 +39,7 @@ test("getClientIdentityProfileHeaders: unknown profile id falls back to no heade test("getClientIdentityProfileHeaders: known CLI profiles expose their preset headers", () => { const claudeCli = getClientIdentityProfileHeaders("claude-cli"); - assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.220 (external, cli)"); + assert.equal(claudeCli["User-Agent"], "claude-cli/2.1.258 (external, cli)"); assert.equal(claudeCli["X-App"], "cli"); const codexCli = getClientIdentityProfileHeaders("codex-cli"); @@ -55,7 +55,7 @@ test("getClientIdentityProfileHeaders: returns a fresh mutable copy (catalog sta headers["User-Agent"] = "tampered"; assert.equal( CLIENT_IDENTITY_PROFILES["claude-cli"].headers["User-Agent"], - "claude-cli/2.1.220 (external, cli)" + "claude-cli/2.1.258 (external, cli)" ); }); @@ -100,7 +100,7 @@ test("profile headers merged into customHeaders survive applyCustomHeaders sanit true ) as Record; - assert.equal(headers["User-Agent"], "claude-cli/2.1.220 (external, cli)"); + assert.equal(headers["User-Agent"], "claude-cli/2.1.258 (external, cli)"); assert.equal(headers["X-App"], "cli"); assert.equal(headers["Authorization"], "Bearer test-key"); }); diff --git a/tests/unit/clinepass-thinking-budget.test.ts b/tests/unit/clinepass-thinking-budget.test.ts index b1452aedaa..7883294d0d 100644 --- a/tests/unit/clinepass-thinking-budget.test.ts +++ b/tests/unit/clinepass-thinking-budget.test.ts @@ -83,11 +83,11 @@ test("bumps undersized max_tokens for a non-clinepass reasoning provider (gate r // Nemotron Nano with supportsReasoning in the NVIDIA registry. const executor = new DefaultExecutor("nvidia"); const body = { - model: "nvidia/nvidia-nemotron-nano-9b-v2", + model: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", reasoning_effort: "high", max_tokens: 100, } as Record; - executor.ensureThinkingBudget(body, "nvidia/nvidia-nemotron-nano-9b-v2"); + executor.ensureThinkingBudget(body, "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"); assert.equal(body.max_tokens, 4096); }); diff --git a/tests/unit/combo-context-relay.test.ts b/tests/unit/combo-context-relay.test.ts index e984d7a696..82e88e6fd5 100644 --- a/tests/unit/combo-context-relay.test.ts +++ b/tests/unit/combo-context-relay.test.ts @@ -404,7 +404,14 @@ test("getLastSessionModel uses latest id as deterministic tie-breaker", async () assert.equal(handoffDb.getLastSessionModel(sessionId, comboName), "anthropic/new"); }); -test("handleComboChat universal handoff does not accumulate injected handoffs across fallback targets", async () => { +test("handleComboChat universal handoff skips same-request fallback targets entirely", async () => { + // #12227 follow-up: a same-request fallback target (i > 0) serves the SAME + // client request the failed primary target would have served -- the client + // never saw the earlier target fail, so there's no genuine "handoff" to + // explain. Injecting one there replaces real conversation content with a + // context-free note; weaker fallback models have been observed fabricating + // content instead of just answering the actual request when handed that + // note. The fallback target must receive the original request untouched. const sessionId = "sess-universal-no-mutate"; const comboName = "universal-no-mutate"; @@ -463,10 +470,8 @@ test("handleComboChat universal handoff does not accumulate injected handoffs ac typeof message?.content === "string" && message.content.includes("") ); - assert.equal(handoffMessages.length, 1); - assert.match(handoffMessages[0].content, /openai\/previous/); - assert.match(handoffMessages[0].content, /anthropic\/fallback/); - assert.doesNotMatch(handoffMessages[0].content, /openai\/failed/); + assert.equal(handoffMessages.length, 0); + assert.deepEqual(fallbackBody.messages, [{ role: "user", content: "Continue" }]); }); test("handleComboChat universal handoff detects model switch before recording current model", async () => { diff --git a/tests/unit/combo-context-window-filter.test.ts b/tests/unit/combo-context-window-filter.test.ts index a78c3d5ba5..74d2646f3e 100644 --- a/tests/unit/combo-context-window-filter.test.ts +++ b/tests/unit/combo-context-window-filter.test.ts @@ -450,3 +450,95 @@ test("without an override the small-catalog target is ordered last for the large ["unit-override/big", "unit-override/capped"] ); }); + +// #12273: real Claude Code requests always carry `tools`, and the auto/coding +// pool mixes coding-capable providers with providers whose catalog marks +// toolCalling=false. Those non-coding targets are HARD-rejected (tools), so the +// compat filter can collapse the whole pool to a single too-small-context +// coding model (e.g. mimo-v2.5-free at 200k) for a much larger request — the +// larger-context model was never assembled into the candidate pool. Routing to +// that sole survivor is a guaranteed context_length_exceeded, so the filter +// must fall back to the full pool instead of silently pinning the request. +test("#12273 single known-too-small survivor falls back to the full pool", () => { + saveModelsDevCapabilities({ + "unit-collapse": { + small: capabilityEntry(200_000), + }, + "unit-noncoding": { + nocoder: { ...capabilityEntry(1_000_000), tool_call: false }, + }, + }); + const body = { + ...bigContextBody(300_000), + tools: [{ type: "function" }], // Claude Code always sends tools + }; + + const out = filterTargetsByRequestCompatibility( + [target("unit-collapse/small"), target("unit-noncoding/nocoder")], + body, + noopLog + ); + + // nocoder is hard-rejected (toolCalling=false); small (200k) is the only + // compatible survivor but is known to be too small for a 300k request, so the + // filter returns the full pool rather than dispatch to a guaranteed failure. + assert.deepEqual( + out.map((entry) => entry.modelStr), + ["unit-collapse/small", "unit-noncoding/nocoder"] + ); +}); + +// Guard against regression: when the single survivor's window DOES fit the +// request, the filter still collapses (existing behavior preserved). +test("#12273 single compatible target that fits is still collapsed", () => { + saveModelsDevCapabilities({ + "unit-collapse": { + big: capabilityEntry(1_000_000), + }, + "unit-noncoding": { + nocoder: { ...capabilityEntry(1_000_000), tool_call: false }, + }, + }); + const body = { + ...bigContextBody(300_000), + tools: [{ type: "function" }], + }; + + const out = filterTargetsByRequestCompatibility( + [target("unit-collapse/big"), target("unit-noncoding/nocoder")], + body, + noopLog + ); + + // big (1M) fits the 300k request, so the collapse is legitimate. + assert.deepEqual( + out.map((entry) => entry.modelStr), + ["unit-collapse/big"] + ); +}); + +// #12278: unknown context is advisory, not "known too small". Collapsing to a +// single survivor whose context limit is unknown must NOT restore hard-rejected +// targets (output_tokens here; vision is covered by combo-vision-aware-routing). +test("#12273 unknown-context sole survivor does not restore hard-rejected targets", () => { + saveModelsDevCapabilities({ + "unit-output": { + tiny: capabilityEntryWithLimits(128_000, 128_000, 4096), + }, + }); + const body = { + messages: [{ role: "user", content: "hello" }], + max_tokens: 32_000, + }; + + const out = filterTargetsByRequestCompatibility( + [target("unit-unknown/mystery"), target("unit-output/tiny")], + body, + noopLog + ); + + assert.deepEqual( + out.map((entry) => entry.modelStr), + ["unit-unknown/mystery"] + ); +}); diff --git a/tests/unit/cursor-auto-catalog-entry.test.ts b/tests/unit/cursor-auto-catalog-entry.test.ts index a194885927..aea92b7394 100644 --- a/tests/unit/cursor-auto-catalog-entry.test.ts +++ b/tests/unit/cursor-auto-catalog-entry.test.ts @@ -28,4 +28,31 @@ describe("ensureCursorAutoCatalogEntry", () => { assert.equal(models.filter((m) => m.id === "auto").length, 1); assert.equal(models.filter((m) => m.id === "auto-cost").length, 1); }); + + it("injects supported 1M context variants immediately before their base ids", () => { + const models = ensureCursorAutoCatalogEntry([ + { id: "claude-opus-5-thinking-max-fast", name: "Claude Opus 5 Max Thinking Fast" }, + { id: "gpt-5.6-sol-max", name: "GPT-5.6 Sol Max" }, + { id: "gpt-5.6-sol-max-fast", name: "GPT-5.6 Sol Max Fast" }, + ]); + const ids = models.map((model) => model.id); + for (const baseId of ["claude-opus-5-thinking-max-fast", "gpt-5.6-sol-max"]) { + const oneMillionPosition = ids.indexOf(`${baseId}-1m`); + assert.ok(oneMillionPosition >= 0); + assert.equal(ids[oneMillionPosition + 1], baseId); + assert.equal( + (models[oneMillionPosition] as { contextLength?: number }).contextLength, + 1_000_000 + ); + } + assert.equal(ids.includes("gpt-5.6-sol-max-fast-1m"), false); + }); + + it("does not duplicate a discovered 1M context variant", () => { + const models = ensureCursorAutoCatalogEntry([ + { id: "gpt-5.6-luna-max-1m", name: "GPT-5.6 Luna 1M Max" }, + { id: "gpt-5.6-luna-max", name: "GPT-5.6 Luna Max" }, + ]); + assert.equal(models.filter((model) => model.id === "gpt-5.6-luna-max-1m").length, 1); + }); }); diff --git a/tests/unit/cursor-available-models.test.ts b/tests/unit/cursor-available-models.test.ts index bf4335b388..28e822427d 100644 --- a/tests/unit/cursor-available-models.test.ts +++ b/tests/unit/cursor-available-models.test.ts @@ -17,7 +17,9 @@ describe("normalizeCursorAvailableModelsPayload", () => { }); assert.equal(models[0].id, "auto"); assert.ok(models.some((m) => m.id === "claude-opus-5-high")); + assert.ok(models.some((m) => m.id === "claude-opus-5-high-1m")); assert.ok(models.some((m) => m.id === "gpt-5.6-sol-high")); + assert.ok(models.some((m) => m.id === "gpt-5.6-sol-high-1m")); assert.ok(models.some((m) => m.id === "auto-cost")); assert.equal(models.find((m) => m.id === "claude-opus-5-high")?.name, "Opus 5"); assert.equal(models.find((m) => m.id === "claude-opus-5-high")?.owned_by, "cursor"); diff --git a/tests/unit/cursor-catalog-combo-compat.test.ts b/tests/unit/cursor-catalog-combo-compat.test.ts index cd3a8f481a..d27613b317 100644 --- a/tests/unit/cursor-catalog-combo-compat.test.ts +++ b/tests/unit/cursor-catalog-combo-compat.test.ts @@ -33,11 +33,11 @@ const LEGACY_GROK_ALIASES = { "grok-4.5-fast-xhigh": "cursor-grok-4.5-xhigh-fast", } as const; -test("keeps legacy Cursor combo model ids in the static catalog", () => { +test("keeps legacy Cursor combo model ids out of the curated static catalog", () => { const catalogIds = new Set(cursorProvider.models.map((model) => model.id)); for (const modelId of LEGACY_CURSOR_COMBO_MODEL_IDS) { - assert.ok(catalogIds.has(modelId), `missing Cursor catalog model: ${modelId}`); + assert.equal(catalogIds.has(modelId), false, `unexpected Cursor catalog model: ${modelId}`); } }); diff --git a/tests/unit/cursor-model-effort-suffix-7289.test.ts b/tests/unit/cursor-model-effort-suffix-7289.test.ts index 10e4023e14..aeb2f9cdb1 100644 --- a/tests/unit/cursor-model-effort-suffix-7289.test.ts +++ b/tests/unit/cursor-model-effort-suffix-7289.test.ts @@ -80,3 +80,35 @@ test("resolveRequestedModel splits cursor-grok effort + fast together", () => { ], }); }); + +test("resolveRequestedModel expands Claude 1M catalog ids into complete wire parameters", () => { + assert.deepEqual(resolveRequestedModel("claude-opus-5-thinking-max-fast-1m"), { + modelId: "claude-opus-5", + parameters: [ + { id: "thinking", value: "true" }, + { id: "context", value: "1m" }, + { id: "effort", value: "max" }, + { id: "fast", value: "true" }, + ], + }); + assert.deepEqual(resolveRequestedModel("claude-4.6-sonnet-high-thinking-1m"), { + modelId: "claude-sonnet-4-6", + parameters: [ + { id: "thinking", value: "true" }, + { id: "context", value: "1m" }, + { id: "effort", value: "high" }, + ], + }); +}); + +test("resolveRequestedModel expands GPT-5.6 1M ids and keeps fast disabled", () => { + const id = "gpt-5.6-sol-xhigh-1m"; + assert.deepEqual(resolveRequestedModel(id, { liveCatalogIds: new Set([id]) }), { + modelId: "gpt-5.6-sol", + parameters: [ + { id: "context", value: "1m" }, + { id: "reasoning", value: "xhigh" }, + { id: "fast", value: "false" }, + ], + }); +}); diff --git a/tests/unit/cursor-registry-claude-families.test.ts b/tests/unit/cursor-registry-claude-families.test.ts index 46a41970a0..0236f99682 100644 --- a/tests/unit/cursor-registry-claude-families.test.ts +++ b/tests/unit/cursor-registry-claude-families.test.ts @@ -1,51 +1,196 @@ -import test from "node:test"; import assert from "node:assert/strict"; +import test from "node:test"; + import { cursorProvider } from "../../open-sse/config/providers/registry/cursor/index.ts"; -const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const; +const CURSOR_FAMILY_REPRESENTATIVES = [ + "cursor-grok-4.6-high-fast", + "composer-2.5", + "claude-fable-5-1-thinking-high", + "claude-opus-5-thinking-high", + "claude-opus-4-8-thinking-high", + "claude-sonnet-5-thinking-high", + "claude-4.6-sonnet-medium-thinking", + "claude-4.5-haiku-thinking", + "gpt-5.6-sol-medium", + "gpt-5.6-terra-medium", + "gpt-5.6-luna-medium", + "gemini-3.7-flash-high", + "gemini-3.1-pro", + "kimi-k3-max", + "kimi-k2.7-code", + "glm-5.2-high", +] as const; -function modelIds(): Set { - return new Set(cursorProvider.models.map((m) => m.id)); -} - -test("cursor registry excludes retired Gemini 3.5 Flash", () => { - assert.equal(modelIds().has("gemini-3.5-flash"), false); +test("cursor registry keeps every selected model family", () => { + const allIds = cursorProvider.models.map((model) => model.id); + const ids = new Set(allIds); + assert.equal(ids.size, allIds.length, "Cursor catalog model ids must be unique"); + for (const id of CURSOR_FAMILY_REPRESENTATIVES) { + assert.ok(ids.has(id), `missing Cursor model: ${id}`); + } }); -test("cursor registry includes Claude Opus 4.8 effort + thinking + fast variants", () => { - const ids = modelIds(); - for (const effort of EFFORTS) { - assert.ok(ids.has(`claude-opus-4-8-${effort}`), `missing claude-opus-4-8-${effort}`); - assert.ok(ids.has(`claude-opus-4-8-${effort}-fast`), `missing claude-opus-4-8-${effort}-fast`); +test("cursor registry omits redundant bare ids for parameterized models", () => { + const ids = new Set(cursorProvider.models.map((model) => model.id)); + for (const id of [ + "grok-4.6", + "claude-fable-5-1", + "claude-opus-5", + "claude-opus-4-8", + "claude-sonnet-5", + "claude-sonnet-4-6", + "claude-haiku-4-5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gemini-3.7-flash", + "kimi-k3", + "glm-5.2", + ]) { + assert.equal(ids.has(id), false, `unexpected bare Cursor model: ${id}`); + } +}); + +test("cursor registry keeps thinking, effort/reasoning and fast variants selectable", () => { + const ids = new Set(cursorProvider.models.map((model) => model.id)); + for (const id of [ + "cursor-grok-4.6-xhigh-fast", + "composer-2.5-fast", + "claude-fable-5-1-thinking-max", + "claude-opus-5-thinking-xhigh-fast", + "claude-opus-4-8-thinking-max-fast", + "claude-sonnet-5-thinking-max", + "claude-4.6-sonnet-max-thinking", + "claude-4.5-haiku-thinking", + "gpt-5.6-sol-max-fast", + "gpt-5.6-terra-max-fast", + "gpt-5.6-luna-max-fast", + "gemini-3.7-flash-high", + "kimi-k3-max", + "glm-5.2-max", + ]) { + assert.ok(ids.has(id), `missing selectable Cursor variant: ${id}`); + } +}); + +test("cursor registry exposes every supported 1M context variant", () => { + const ids = cursorProvider.models.map((model) => model.id); + const oneMillionVariants = cursorProvider.models.filter((model) => model.id.endsWith("-1m")); + assert.equal(oneMillionVariants.length, 77); + for (const variant of oneMillionVariants) { + assert.match(variant.name, /\b1M\b/); + assert.equal(variant.contextLength, 1_000_000); + const position = ids.indexOf(variant.id); + assert.equal(ids[position + 1], variant.id.slice(0, -"-1m".length)); + } + for (const id of [ + "claude-fable-5-1-thinking-max-1m", + "claude-opus-5-thinking-max-fast-1m", + "claude-opus-4-8-thinking-max-fast-1m", + "claude-sonnet-5-thinking-max-1m", + "claude-4.6-sonnet-max-thinking-1m", + "gpt-5.6-sol-max-1m", + "gpt-5.6-terra-max-1m", + "gpt-5.6-luna-max-1m", + ]) { assert.ok( - ids.has(`claude-opus-4-8-thinking-${effort}`), - `missing claude-opus-4-8-thinking-${effort}` + oneMillionVariants.some((model) => model.id === id), + `missing 1M variant: ${id}` ); + } + assert.equal( + oneMillionVariants.some( + (model) => model.id.startsWith("gpt-5.6-") && model.id.includes("-fast") + ), + false, + "Cursor does not offer fast processing with GPT-5.6 1M context" + ); +}); + +test("cursor registry records the default context for context-selectable families", () => { + const models = new Map(cursorProvider.models.map((model) => [model.id, model])); + assert.equal(models.get("claude-fable-5-1-thinking-max")?.contextLength, 300_000); + assert.equal(models.get("claude-opus-5-thinking-max")?.contextLength, 300_000); + assert.equal(models.get("claude-opus-4-8-thinking-max")?.contextLength, 300_000); + assert.equal(models.get("claude-sonnet-5-thinking-max")?.contextLength, 300_000); + assert.equal(models.get("claude-4.6-sonnet-max-thinking")?.contextLength, 200_000); + assert.equal(models.get("gpt-5.6-sol-max")?.contextLength, 272_000); + assert.equal(models.get("gpt-5.6-terra-max")?.contextLength, 272_000); + assert.equal(models.get("gpt-5.6-luna-max")?.contextLength, 272_000); +}); + +test("cursor registry orders each model family by quality, thinking and speed", () => { + const ids = cursorProvider.models.map((model) => model.id); + for (const orderedIds of [ + ["cursor-grok-4.6-xhigh-fast", "cursor-grok-4.6-xhigh", "cursor-grok-4.6-low"], + ["composer-2.5-fast", "composer-2.5"], + ["claude-fable-5-1-thinking-max", "claude-fable-5-1-thinking-low"], + ["claude-opus-5-thinking-high-fast", "claude-opus-5-high-fast", "claude-opus-5-low"], + ["claude-opus-4-8-thinking-max-fast", "claude-opus-4-8-max-fast", "claude-opus-4-8-low"], + ["claude-sonnet-5-thinking-max", "claude-sonnet-5-max", "claude-sonnet-5-low"], + ["claude-4.6-sonnet-max-thinking", "claude-4.6-sonnet-max", "claude-4.6-sonnet-low"], + ["claude-4.5-haiku-thinking", "claude-4.5-haiku"], + ["gpt-5.6-sol-max-fast", "gpt-5.6-sol-max", "gpt-5.6-sol-none"], + ["gpt-5.6-terra-max-fast", "gpt-5.6-terra-max", "gpt-5.6-terra-none"], + ["gpt-5.6-luna-max-fast", "gpt-5.6-luna-max", "gpt-5.6-luna-none"], + ["gemini-3.7-flash-high", "gemini-3.7-flash-low"], + ["kimi-k3-max", "kimi-k3-low"], + ["glm-5.2-max", "glm-5.2-high"], + ]) { + const positions = orderedIds.map((id) => ids.indexOf(id)); assert.ok( - ids.has(`claude-opus-4-8-thinking-${effort}-fast`), - `missing claude-opus-4-8-thinking-${effort}-fast` + positions.every((position) => position >= 0), + `missing ordered ids: ${orderedIds}` + ); + assert.deepEqual( + positions, + [...positions].sort((left, right) => left - right) ); } }); -test("cursor registry includes Claude Fable 5 effort + thinking variants", () => { - const ids = modelIds(); - for (const effort of EFFORTS) { - assert.ok(ids.has(`claude-fable-5-${effort}`), `missing claude-fable-5-${effort}`); - assert.ok( - ids.has(`claude-fable-5-thinking-${effort}`), - `missing claude-fable-5-thinking-${effort}` +test("cursor registry uses compact Xhigh labels", () => { + const xhighVariants = cursorProvider.models.filter((model) => model.id.includes("xhigh")); + assert.ok(xhighVariants.length > 0); + for (const variant of xhighVariants) { + assert.match(variant.name, /\bXhigh\b/); + assert.doesNotMatch(variant.name, /Extra High/); + } +}); + +test("cursor registry excludes unrelated model families", () => { + const ids = cursorProvider.models.map((model) => model.id); + for (const fragment of [ + "grok-4.5", + "gpt-5.5", + "gpt-5.4", + "gpt-5.3", + "gpt-5.2", + "claude-fable-5-thinking", + "claude-opus-4-7", + "gemini-3.6-flash", + "gemini-3.5-flash", + "gemini-3-flash", + ]) { + assert.equal( + ids.some((id) => id.includes(fragment)), + false, + `unexpected Cursor model family: ${fragment}` ); } }); -test("cursor registry includes Claude Sonnet 5 effort + thinking variants", () => { - const ids = modelIds(); - for (const effort of EFFORTS) { - assert.ok(ids.has(`claude-sonnet-5-${effort}`), `missing claude-sonnet-5-${effort}`); - assert.ok( - ids.has(`claude-sonnet-5-thinking-${effort}`), - `missing claude-sonnet-5-thinking-${effort}` - ); +test("cursor registry keeps Fable 5.1 capability metadata on every selectable variant", () => { + const variants = cursorProvider.models.filter((model) => + model.id.startsWith("claude-fable-5-1-thinking-") + ); + assert.equal(variants.length, 10); + assert.deepEqual( + new Set(variants.map((variant) => variant.contextLength)), + new Set([300_000, 1_000_000]) + ); + for (const variant of variants) { + assert.equal(variant.maxOutputTokens, 128_000); } }); diff --git a/tests/unit/daily-quota-reset.test.ts b/tests/unit/daily-quota-reset.test.ts new file mode 100644 index 0000000000..0d2c78cfaf --- /dev/null +++ b/tests/unit/daily-quota-reset.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + isValidIanaTimeZone, + isValidResetHour, + nodeDailyResetConfigured, + nextDailyResetAtMs, + parseTpdLimitFromText, +} from "../../open-sse/services/dailyQuotaReset.ts"; + +test("IANA: Asia/Shanghai ok, garbage rejected", () => { + assert.equal(isValidIanaTimeZone("Asia/Shanghai"), true); + assert.equal(isValidIanaTimeZone("America/New_York"), true); + assert.equal(isValidIanaTimeZone("Not/AZone"), false); + assert.equal(isValidIanaTimeZone(""), false); +}); + +test("isValidResetHour accepts 0-23 integers only", () => { + assert.equal(isValidResetHour(0), true); + assert.equal(isValidResetHour(23), true); + assert.equal(isValidResetHour(24), false); + assert.equal(isValidResetHour(-1), false); + assert.equal(isValidResetHour(1.5), false); + assert.equal(isValidResetHour(null), false); +}); + +test("nodeDailyResetConfigured requires both fields", () => { + assert.equal(nodeDailyResetConfigured("Asia/Shanghai", 0), true); + assert.equal(nodeDailyResetConfigured("Asia/Shanghai", null), false); + assert.equal(nodeDailyResetConfigured(null, 0), false); + assert.equal(nodeDailyResetConfigured("Not/AZone", 0), false); + assert.equal(nodeDailyResetConfigured("Asia/Shanghai", 24), false); +}); + +test("nextDailyResetAtMs locks to next local hour:00", () => { + // 2026-09-02 15:30 in Asia/Shanghai = 2026-09-02 07:30 UTC + const now = Date.parse("2026-09-02T07:30:00Z"); + const next = nextDailyResetAtMs("Asia/Shanghai", 0, now); + // next calendar day 00:00 Shanghai = 2026-09-02 16:00 UTC + assert.equal(next, Date.parse("2026-09-02T16:00:00Z")); +}); + +test("nextDailyResetAtMs at exact reset instant returns the following cycle", () => { + const exactly = Date.parse("2026-09-02T16:00:00Z"); // 00:00 Shanghai + const next = nextDailyResetAtMs("Asia/Shanghai", 0, exactly); + assert.equal(next, Date.parse("2026-09-03T16:00:00Z")); +}); + +test("parseTpdLimitFromText reads limit: from live body", () => { + const body = + "request reached organization TPD rate limit, current: 1537190, limit: 1500000"; + assert.equal(parseTpdLimitFromText(body), 1_500_000); + assert.equal(parseTpdLimitFromText("no numbers"), null); +}); diff --git a/tests/unit/devin-cli-catalog.test.ts b/tests/unit/devin-cli-catalog.test.ts index 7cd92e15b1..330ee8ac31 100644 --- a/tests/unit/devin-cli-catalog.test.ts +++ b/tests/unit/devin-cli-catalog.test.ts @@ -2,54 +2,124 @@ import assert from "node:assert/strict"; import test from "node:test"; import { devin_cliProvider } from "../../open-sse/config/providers/registry/devin-cli/index.ts"; +import { devin_cli_agenticProvider } from "../../open-sse/config/providers/registry/devin-cli-agentic/index.ts"; import { devin_desktopProvider } from "../../open-sse/config/providers/registry/devin-desktop/index.ts"; import { DEVIN_MODEL_CATALOG } from "../../open-sse/config/providers/registry/devin/catalog.ts"; +import { DEVIN_MODEL_PRICING } from "../../src/shared/constants/pricing/devin.ts"; +import { DEFAULT_PRICING, getPricingForModel } from "../../src/shared/constants/pricing.ts"; -test("Devin CLI and Desktop use the shared catalog without duplicate model ids", () => { - const ids = DEVIN_MODEL_CATALOG.map((model) => model.id); +const catalogIds = DEVIN_MODEL_CATALOG.map((model) => model.id); +test("Devin transports expose the same curated catalog without duplicate ids", () => { assert.equal(devin_cliProvider.models, DEVIN_MODEL_CATALOG); assert.equal(devin_desktopProvider.models, DEVIN_MODEL_CATALOG); - assert.equal(new Set(ids).size, ids.length); - assert.ok(ids.every((id) => !id.toLowerCase().includes("byok"))); + assert.deepEqual( + devin_cli_agenticProvider.models.map((model) => model.id), + catalogIds + ); + assert.equal(catalogIds.length, 110); + assert.equal(new Set(catalogIds).size, catalogIds.length); + assert.ok(catalogIds.every((id) => !id.toLowerCase().includes("byok"))); }); -test("Devin CLI catalog includes the refreshed native model ids", () => { - const ids = new Set(DEVIN_MODEL_CATALOG.map((model) => model.id)); +test("Devin catalog contains only the operator-selected model families", () => { + const required = [ + "claude-fable-5-1-max", + "claude-opus-5-max-fast", + "claude-opus-4-8-max-fast", + "claude-sonnet-5-max", + "claude-sonnet-4-6-thinking-1m", + "MODEL_PRIVATE_11", + "gpt-5-6-sol-max-priority", + "gpt-5-6-terra-max-priority", + "gpt-5-6-luna-max-priority", + "kimi-k3-max", + "kimi-k2-7", + "glm-5-3-max", + "glm-5-3-flash-max", + "swe-1-7", + "swe-1-7-lightning", + "adaptive", + "grok-4-6-xhigh", + "inkling-max", + "deepseek-v4-flash-max", + "nemotron-3-ultra-high", + "gemini-3-7-flash-high", + "gemini-3-1-pro-high", + "deepseek-v4-pro-max", + ]; + + for (const id of required) { + assert.ok(catalogIds.includes(id), `expected selected Devin model id: ${id}`); + } for (const id of [ - "swe-1-7-lightning", "claude-5-fable-max", - "gpt-5-6-sol-max", + "claude-opus-4-7-max", "gpt-5-5-high", - "glm-5-2-max-1m", - "claude-opus-5-low", - "claude-opus-5-medium", - "claude-opus-5-high", - "claude-opus-5-xhigh", - "claude-opus-5-max", - "gemini-3-7-flash-minimal", - "gemini-3-7-flash-low", - "gemini-3-7-flash-medium", - "gemini-3-7-flash-high", - "kimi-k3-low", - "kimi-k3-high", - "kimi-k3-max", - "inkling-none", - "inkling-low", - "inkling-medium", - "inkling-high", - "inkling-xhigh", - "inkling-max", + "gemini-3-6-flash-high", + "grok-4-5-high", + "deepseek-v4", + "nemotron-3-ultra-nvfp4", + "swe-1-6-fast", ]) { - assert.ok(ids.has(id), `expected refreshed Devin model id: ${id}`); + assert.equal(catalogIds.includes(id), false, `unselected Devin model must stay absent: ${id}`); } }); -test("Devin CLI catalog does not expose retired dotted or review model ids", () => { - const ids = new Set(DEVIN_MODEL_CATALOG.map((model) => model.id)); +test("Devin catalog keeps higher-quality choices first", () => { + assert.deepEqual(catalogIds.slice(0, 5), [ + "claude-fable-5-1-max", + "claude-fable-5-1-xhigh", + "claude-fable-5-1-high", + "claude-fable-5-1-medium", + "claude-fable-5-1-low", + ]); + assert.deepEqual(catalogIds.slice(5, 9), [ + "claude-opus-5-max-fast", + "claude-opus-5-max", + "claude-opus-5-xhigh-fast", + "claude-opus-5-xhigh", + ]); +}); - for (const id of ["swe-1.6-fast", "swe-1.6", "claude-opus-4.7-review"]) { - assert.equal(ids.has(id), false, `retired Devin model id must stay absent: ${id}`); +test("every curated Devin model has an exact live provider price", () => { + assert.deepEqual(new Set(Object.keys(DEVIN_MODEL_PRICING)), new Set(catalogIds)); + + for (const provider of ["devin-cli", "dv", "devin-desktop", "devin-cli-agentic", "dva"]) { + for (const id of catalogIds) { + assert.ok(getPricingForModel(provider, id), `missing ${provider}/${id} pricing`); + } } }); + +test("Devin pricing remains provider-bound and preserves fast-tier rates", () => { + assert.notEqual(DEFAULT_PRICING["devin-cli"], DEFAULT_PRICING.anthropic); + assert.deepEqual(getPricingForModel("devin-cli", "claude-sonnet-5-max"), { + input: 2, + cached: 0.2, + output: 10, + }); + assert.deepEqual(getPricingForModel("anthropic", "claude-sonnet-5"), { + input: 3, + output: 15, + cached: 1.5, + reasoning: 22.5, + cache_creation: 3, + }); + assert.deepEqual(getPricingForModel("devin-cli", "gpt-5-6-sol-max-priority"), { + input: 8, + cached: 0.8, + output: 40, + }); +}); + +test("Devin catalog carries the live output limits for representative models", () => { + const models = new Map(DEVIN_MODEL_CATALOG.map((entry) => [entry.id, entry])); + + assert.equal(models.get("claude-fable-5-1-max")?.maxOutputTokens, 128_000); + assert.equal(models.get("MODEL_PRIVATE_11")?.maxOutputTokens, 64_000); + assert.equal(models.get("kimi-k2-7")?.maxOutputTokens, 16_000); + assert.equal(models.get("grok-4-6-xhigh")?.maxOutputTokens, 100_000); + assert.equal(models.get("gemini-3-7-flash-high")?.maxOutputTokens, 65_535); +}); diff --git a/tests/unit/electron-lazy-window.test.ts b/tests/unit/electron-lazy-window.test.ts index f9d3f97677..08515bd570 100644 --- a/tests/unit/electron-lazy-window.test.ts +++ b/tests/unit/electron-lazy-window.test.ts @@ -8,14 +8,31 @@ const require = createRequire(import.meta.url); const { shouldStartHidden, showOrCreateWindow } = require("../../electron/lib/windowLifecycle"); describe("Electron hidden-start window lifecycle", () => { - it("detects explicit hidden flags and OS login-item hidden launches", () => { + it("detects explicit hidden flags", () => { assert.equal(shouldStartHidden({ argv: ["electron", "--hidden"] }), true); assert.equal(shouldStartHidden({ argv: ["electron", "--minimized"] }), true); + assert.equal(shouldStartHidden({ argv: ["electron"] }), false); + assert.equal(shouldStartHidden(), false); + }); + + // Electron 44 removed `wasOpenedAsHidden` from `app.getLoginItemSettings()`, so a hidden + // autostart is signalled ONLY by the `--hidden` argument the login item registers. Guards + // against re-introducing a dependency on the removed field. + it("ignores login-item settings entirely", () => { assert.equal( shouldStartHidden({ argv: ["electron"], loginItemSettings: { wasOpenedAsHidden: true } }), + false + ); + assert.equal( + shouldStartHidden({ argv: ["electron", "--hidden"], loginItemSettings: {} }), true ); - assert.equal(shouldStartHidden({ argv: ["electron"], loginItemSettings: {} }), false); + }); + + it("keeps the --hidden argument registered with the login item", () => { + const mainJs = readFileSync(join(import.meta.dirname, "../../electron/main.js"), "utf8"); + assert.match(mainJs, /openAtLogin: true,\s*\n\s*args: \["--hidden"\],/); + assert.doesNotMatch(mainJs, /openAsHidden/); }); it("creates the dashboard only when an explicit open action has no live window", () => { diff --git a/tests/unit/embedding-clova-v2.test.ts b/tests/unit/embedding-clova-v2.test.ts new file mode 100644 index 0000000000..9a97a1c5ee --- /dev/null +++ b/tests/unit/embedding-clova-v2.test.ts @@ -0,0 +1,258 @@ +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"; + +// Naver CLOVA Studio embedding v2. +// +// The endpoint embeds exactly ONE text per request (`{"text": …}` → one vector) +// and answers `{status, result:{embedding:[…1024 floats], inputTokens}}`, so a +// batched `/v1/embeddings` call has to be fanned out into N upstream calls and +// merged back into OpenAI's list shape. +// +// Live-verified against the API on 2026-09-01: 1024 dimensions, ~100ms per call, +// and an empty string is rejected with `40004 Text empty`. + +const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-clova-embeddings-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.SQLITE_FILE = join(TEST_DATA_DIR, "storage.sqlite"); + +const registry = await import("../../open-sse/config/embeddingRegistry.ts"); +const { normalizeClovaEmbeddingV2Response } = + await import("../../open-sse/handlers/embeddingStructuredInput.ts"); +const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts"); +const { resetDbInstance } = await import("../../src/lib/db/core.ts"); + +test.after(async () => { + // handleEmbedding records call logs asynchronously; let those writes settle + // before closing the singleton so a late write cannot reopen the test DB. + await new Promise((resolve) => setTimeout(resolve, 50)); + resetDbInstance(); +}); + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +test("clova embedding v2 is registered with the single-text protocol", () => { + const provider = registry.getEmbeddingProvider("clova-studio"); + assert.ok(provider, "clova-studio must be an embedding provider"); + assert.equal(provider.baseUrl, "https://clovastudio.stream.ntruss.com/v1/api-tools/embedding/v2"); + assert.equal(provider.singleTextProtocol, "clova-v2"); + assert.equal(provider.authType, "apikey"); + assert.equal(provider.authHeader, "bearer"); + assert.deepEqual(provider.models, [ + { id: "clova-embedding-v2", name: "CLOVA Embedding v2", dimensions: 1024 }, + ]); +}); + +test("clova embedding v2 resolves its model and dimension", () => { + assert.deepEqual(registry.parseEmbeddingModel("clova-studio/clova-embedding-v2"), { + provider: "clova-studio", + model: "clova-embedding-v2", + }); + assert.equal(registry.getEmbeddingDimension("clova-studio/clova-embedding-v2"), 1024); +}); + +// --------------------------------------------------------------------------- +// Response normalisation +// --------------------------------------------------------------------------- + +test("a success envelope is normalised into OpenAI list shape", () => { + const normalized = normalizeClovaEmbeddingV2Response({ + status: { code: "20000", message: "OK" }, + result: { embedding: [0.1, -0.2, 0.3], inputTokens: 4 }, + }); + assert.deepEqual(normalized, { + data: [{ object: "embedding", index: 0, embedding: [0.1, -0.2, 0.3] }], + usage: { prompt_tokens: 4, total_tokens: 4 }, + }); +}); + +test("a failure envelope is rejected instead of becoming an empty success", () => { + assert.throws( + () => + normalizeClovaEmbeddingV2Response({ + status: { code: "40004", message: "Text empty" }, + }), + /unsuccessful status/ + ); +}); + +test("a payload without an embedding vector is rejected", () => { + assert.throws( + () => + normalizeClovaEmbeddingV2Response({ + status: { code: "20000" }, + result: { inputTokens: 0 }, + }), + /missing an embedding vector/ + ); +}); + +// --------------------------------------------------------------------------- +// Batch fan-out through the real handler +// --------------------------------------------------------------------------- + +const originalFetch = globalThis.fetch; + +function mockClova(calls: Array>): void { + globalThis.fetch = (async (_url: string, init: RequestInit) => { + calls.push(JSON.parse(String(init.body))); + const text = String((calls[calls.length - 1] as { text?: string }).text ?? ""); + return new Response( + JSON.stringify({ + status: { code: "20000", message: "OK" }, + result: { embedding: [text.length, 1, 2], inputTokens: text.length }, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + }) as typeof fetch; +} + +function mockClovaEnvelope( + calls: Array>, + envelope: Record +): void { + globalThis.fetch = (async (_url: string, init: RequestInit) => { + calls.push(JSON.parse(String(init.body))); + return new Response(JSON.stringify(envelope), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as typeof fetch; +} + +test("a batched input is fanned out into one upstream call per text", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: ["alpha", "beta", "gamma"] }, + credentials: { apiKey: "test-key" }, + }); + + assert.equal(result.success, true, JSON.stringify(result)); + // One request per text — the endpoint cannot batch. + assert.deepEqual( + calls.map((c) => c.text), + ["alpha", "beta", "gamma"] + ); + + const data = (result as { data: Record }).data; + assert.equal(data.object, "list"); + assert.equal(data.model, "clova-studio/clova-embedding-v2"); + assert.equal((data.data as unknown[]).length, 3); + // Indexes must reflect the caller's positions, not each upstream call's 0. + assert.deepEqual( + (data.data as Array<{ index: number }>).map((d) => d.index), + [0, 1, 2] + ); + // Token usage is summed across the fan-out. + assert.equal((data.usage as { prompt_tokens: number }).prompt_tokens, 5 + 4 + 5); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an empty text rejects the batch without changing response indexes", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: ["", " ", "real"] }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 400); + assert.equal(calls.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an all-empty input fails without calling upstream", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: ["", ""] }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 400); + assert.equal(calls.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a single string input takes the fan-out path too", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: "solo" }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, true, JSON.stringify(result)); + assert.deepEqual( + calls.map((c) => c.text), + ["solo"] + ); + assert.equal(((result as { data: Record }).data.data as unknown[]).length, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("an HTTP-200 CLOVA error envelope becomes a provider failure", async () => { + const calls: Array> = []; + mockClovaEnvelope(calls, { status: { code: "40004", message: "Text empty" } }); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: "text" }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 502); + assert.equal(calls.length, 1); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("token-array input is rejected instead of being silently dropped", async () => { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: [101, 202] }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 400); + assert.equal(calls.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("unsupported output options are rejected instead of ignored", async () => { + for (const extra of [{ encoding_format: "base64" }, { dimensions: 1536 }]) { + const calls: Array> = []; + mockClova(calls); + try { + const result = await handleEmbedding({ + body: { model: "clova-studio/clova-embedding-v2", input: "text", ...extra }, + credentials: { apiKey: "test-key" }, + }); + assert.equal(result.success, false); + assert.equal((result as { status: number }).status, 400); + assert.equal(calls.length, 0); + } finally { + globalThis.fetch = originalFetch; + } + } +}); diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index 0e21678054..469ffe992c 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -1571,12 +1571,12 @@ test("DefaultExecutor.execute does not produce duplicate anthropic-version heade assert.equal(versionKeys.length, 1, "Duplicate anthropic-version header keys found"); assert.equal(capturedHeaders[versionKeys[0]], "2023-06-01"); assert.equal(capturedHeaders["X-Stainless-Runtime-Version"], "v26.3.0"); - assert.equal(capturedHeaders["X-Stainless-Package-Version"], "0.94.0"); + assert.equal(capturedHeaders["X-Stainless-Package-Version"], "0.112.1"); const sentBody = JSON.parse(capturedBody) as { system?: Array<{ text?: string }> }; assert.match( sentBody.system?.[0]?.text ?? "", - /^x-anthropic-billing-header: cc_version=2\.1\.220\.1f2; cc_entrypoint=cli; cch=[0-9a-f]{5};$/ + /^x-anthropic-billing-header: cc_version=2\.1\.258\.1e2; cc_entrypoint=cli; cch=[0-9a-f]{5};$/ ); }); diff --git a/tests/unit/executor-zai-web.test.ts b/tests/unit/executor-zai-web.test.ts index aa26418f9c..ec77c94f5e 100644 --- a/tests/unit/executor-zai-web.test.ts +++ b/tests/unit/executor-zai-web.test.ts @@ -30,7 +30,7 @@ function installZaiFetch( const value = String(url); if (value === ZAI_HOME_URL) { return new Response( - '' + '' ); } if (value === ZAI_NEW_CHAT_URL) { @@ -90,13 +90,18 @@ describe("ZaiWebExecutor", () => { "Z.ai browser transport failed (502; capture 30001ms, total 33412ms): " + "browserBackedChat failed: response.body unavailable" ); - assert.match( + assert.equal( mod.describeZaiBrowserFailure({ status: 0, body: Buffer.alloc(0), + observedPostUrls: ["https://chat.z.ai/api/v1/chats/new"], + observedPostResponses: [{ url: "https://chat.z.ai/api/v1/chats/new", status: 200 }], timing: { captureResponseMs: 30_000, totalMs: 33_000 }, }), - /no matching response.*did not issue the expected authenticated chat completion request/ + "Z.ai browser transport failed (no matching response; capture 30000ms, total 33000ms): " + + "The page did not issue the expected authenticated chat completion request. " + + "Observed POST targets: https://chat.z.ai/api/v1/chats/new. " + + "Observed POST responses: https://chat.z.ai/api/v1/chats/new [200]." ); }); @@ -128,9 +133,9 @@ describe("ZaiWebExecutor", () => { it("parses the deployed frontend version from the homepage asset path", () => { assert.equal( mod.parseZaiFrontendVersion( - "https://z-cdn.chatglm.cn/z-ai/frontend/prod-fe-1.1.79/assets/index.js" + "https://z-cdn.chatglm.cn/z-ai/frontend/prod-fe-1.1.92/assets/index.js" ), - "prod-fe-1.1.79" + "prod-fe-1.1.92" ); assert.equal(mod.parseZaiFrontendVersion(""), null); }); @@ -211,86 +216,87 @@ describe("ZaiWebExecutor", () => { ]); }); - it("enables Deep Think for every public model and limits effort to GLM-5.2", () => { - assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.2", {}), { + it("maps the three public models to their current Deep Think effort vocabularies", () => { + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.3-flash", { reasoning_effort: "low" }), { supported: true, enabled: true, - effort: "max", + effort: "low", effortSupported: true, }); - assert.deepEqual(mod.resolveZaiThinkingConfig("zw/glm-5.2", { reasoning_effort: "medium" }), { + assert.deepEqual(mod.resolveZaiThinkingConfig("zw/glm-5.3", { reasoning_effort: "medium" }), { supported: true, enabled: true, effort: "high", effortSupported: true, }); - assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.2", { reasoning: { effort: "high" } }), { + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.3", { reasoning: { effort: "high" } }), { supported: true, enabled: true, effort: "high", effortSupported: true, }); - assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.2", { reasoning_effort: "off" }), { - supported: true, - enabled: false, - effort: "max", - effortSupported: true, - }); - assert.deepEqual(mod.resolveZaiThinkingConfig("GLM-5.1", { reasoning_effort: "max" }), { + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.3", { reasoning_effort: "off" }), { supported: true, enabled: true, effort: "max", - effortSupported: false, + effortSupported: true, + }); + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.3", { enable_thinking: false }), { + supported: true, + enabled: true, + effort: "max", + effortSupported: true, + }); + assert.deepEqual(mod.resolveZaiThinkingConfig("glm-5.2", { reasoning_effort: "low" }), { + supported: true, + enabled: true, + effort: "high", + effortSupported: true, }); }); - it("maps GLM-5V-Turbo vision and internal VLM controls from live capabilities", () => { - assert.deepEqual(mod.getZaiModelCapabilities("zw/GLM-5v-Turbo"), { + it("maps GLM-5.3-Flash vision and web controls from live capabilities", () => { + assert.deepEqual(mod.getZaiModelCapabilities("zw/glm-5.3-flash"), { mcp: false, - reasoningEffort: false, + reasoningEffort: true, returnFc: true, thinking: true, vision: true, - vlmTools: true, - vlmWebSearch: true, - vlmWebsiteMode: true, + vlmTools: false, + vlmWebSearch: false, + vlmWebsiteMode: false, webSearch: true, }); - assert.deepEqual(mod.resolveZaiVlmConfig("GLM-5v-Turbo", {}), { - toolsEnabled: true, - webSearchEnabled: true, - websiteModeEnabled: true, + assert.deepEqual(mod.getZaiModelCapabilities("x-preview-l"), { + mcp: false, + reasoningEffort: true, + returnFc: true, + thinking: true, + vision: true, + vlmTools: false, + vlmWebSearch: false, + vlmWebsiteMode: false, + webSearch: true, }); - assert.deepEqual( - mod.resolveZaiVlmConfig("GLM-5v-Turbo", { - features: { - vlm_tools_enable: false, - vlm_web_search_enable: false, - vlm_website_mode: false, - }, - }), - { - toolsEnabled: false, - webSearchEnabled: false, - websiteModeEnabled: true, - } - ); - assert.deepEqual(mod.resolveZaiVlmConfig("GLM-5.1", {}), { + assert.deepEqual(mod.resolveZaiVlmConfig("glm-5.3-flash", { web_search: true }), { + toolsEnabled: false, + webSearchEnabled: true, + websiteModeEnabled: false, + }); + assert.deepEqual(mod.resolveZaiVlmConfig("glm-5.3", {}), { toolsEnabled: false, webSearchEnabled: false, websiteModeEnabled: false, }); - assert.deepEqual(mod.resolveZaiVlmConfig("GLM-5.1", { web_search: true }), { - toolsEnabled: false, - webSearchEnabled: true, - websiteModeEnabled: false, - }); + assert.equal(mod.zaiUpstreamModelId("zai-web/glm-5.3-flash"), "x-preview-l"); + assert.equal(mod.zaiUpstreamModelId("zai-web/glm-5.3"), "glm-5.3"); + assert.equal(mod.getZaiModelCapabilities("GLM-5.1").thinking, false); }); it("returns a credential error when no session credential is provided", async () => { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: "" }, @@ -313,7 +319,6 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "glm-5.2", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: TEST_TOKEN }, @@ -322,8 +327,10 @@ describe("ZaiWebExecutor", () => { const completion = await result.response.json(); assert.equal(completion.choices[0].message.content, "Browser"); + assert.equal(completion.model, "glm-5.3"); assert.equal(capturedRequest?.localStorage?.token, TEST_TOKEN); assert.equal(capturedRequest?.localStorageOrigin, "https://chat.z.ai"); + assert.equal(capturedRequest?.headless, false); assert.equal(capturedRequest?.inputSelector, "#chat-input"); assert.equal( capturedRequest?.submitButtonSelector, @@ -331,7 +338,7 @@ describe("ZaiWebExecutor", () => { ); assert.equal(capturedRequest?.submitButtonMode, "dom"); assert.equal(capturedRequest?.userMessage, "hi"); - assert.match(capturedRequest?.chatPageUrl ?? "", /model=GLM-5\.2/); + assert.match(capturedRequest?.chatPageUrl ?? "", /model=GLM-5\.3/); assert.equal(typeof capturedRequest?.beforeSubmit, "function"); assert.equal(result.headers["X-OmniRoute-Transport"], "browser"); assert.equal(result.transformedBody.browser_backed, true); @@ -342,7 +349,7 @@ describe("ZaiWebExecutor", () => { } }); - it("configures GLM-5V-Turbo controls on the browser transport", async () => { + it("configures GLM-5.3-Flash on the browser transport", async () => { let capturedRequest: BrowserBackedChatRequest | null = null; browserChat.__setBrowserBackedChatOverrideForTesting(async (request) => { capturedRequest = request; @@ -352,8 +359,8 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5v-Turbo", - body: { messages: [{ role: "user", content: "use the model tools" }] }, + model: "glm-5.3-flash", + body: { messages: [{ role: "user", content: "use flash" }] }, stream: false, credentials: { apiKey: TEST_TOKEN }, signal: null, @@ -361,19 +368,19 @@ describe("ZaiWebExecutor", () => { const completion = await result.response.json(); assert.equal(completion.choices[0].message.content, "VLM"); - assert.match(capturedRequest?.chatPageUrl ?? "", /model=GLM-5V-Turbo/); + assert.match(capturedRequest?.chatPageUrl ?? "", /model=GLM-5\.3-Flash/); assert.equal(typeof capturedRequest?.beforeSubmit, "function"); assert.equal(result.transformedBody.enable_thinking, true); - assert.equal(result.transformedBody.vlm_tools_enable, true); - assert.equal(result.transformedBody.vlm_web_search_enable, true); - assert.equal(result.transformedBody.vlm_website_mode, true); - assert.equal("reasoning_effort" in result.transformedBody, false); + assert.equal(result.transformedBody.reasoning_effort, "max"); + assert.equal(result.transformedBody.vlm_tools_enable, false); + assert.equal(result.transformedBody.vlm_web_search_enable, false); + assert.equal(result.transformedBody.vlm_website_mode, false); } finally { browserChat.__resetBrowserBackedChatOverrideForTesting(); } }); - it("uploads GLM-5V-Turbo image input through the authenticated browser page", async () => { + it("uploads GLM-5.3-Flash image input through the authenticated browser page", async () => { let capturedRequest: BrowserBackedChatRequest | null = null; browserChat.__setBrowserBackedChatOverrideForTesting(async (request) => { capturedRequest = request; @@ -383,7 +390,7 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5v-Turbo", + model: "glm-5.3-flash", body: { messages: [ { @@ -445,7 +452,7 @@ describe("ZaiWebExecutor", () => { assert.equal(result.response.status, 400); const parsed = await result.response.json(); - assert.match(parsed.error.message, /use GLM-5V-Turbo/); + assert.match(parsed.error.message, /use GLM-5\.3-Flash/); }); it("creates a chat, signs the v2 request, and forwards the CAPTCHA proof", async () => { @@ -461,9 +468,9 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { - model: "GLM-5.1", + model: "glm-5.3", messages: [{ role: "user", content: "hello" }], temperature: 0.4, web_search: true, @@ -477,7 +484,7 @@ describe("ZaiWebExecutor", () => { const newChatHeaders = capture.newChatInit?.headers as Record; assert.equal(newChatHeaders.Authorization, `Bearer ${TEST_TOKEN}`); const newChatBody = JSON.parse(String(capture.newChatInit?.body)); - assert.deepEqual(newChatBody.chat.models, ["GLM-5.1"]); + assert.deepEqual(newChatBody.chat.models, ["glm-5.3"]); assert.equal(newChatBody.chat.history.currentId.length, 36); assert.equal(newChatBody.chat.enable_thinking, true); assert.equal(newChatBody.chat.auto_web_search, true); @@ -494,11 +501,11 @@ describe("ZaiWebExecutor", () => { const headers = capture.completionInit?.headers as Record; assert.equal(headers.Authorization, `Bearer ${TEST_TOKEN}`); - assert.equal(headers["X-FE-Version"], "prod-fe-1.1.79"); + assert.equal(headers["X-FE-Version"], "prod-fe-1.1.92"); assert.match(headers["X-Signature"], /^[a-f0-9]{64}$/); const parsedBody = JSON.parse(String(capture.completionInit?.body)); - assert.equal(parsedBody.model, "GLM-5.1"); + assert.equal(parsedBody.model, "glm-5.3"); assert.equal(parsedBody.stream, true); assert.deepEqual(parsedBody.messages, [{ role: "user", content: "hello" }]); assert.equal(parsedBody.signature_prompt, "hello"); @@ -508,7 +515,7 @@ describe("ZaiWebExecutor", () => { assert.equal(parsedBody.features.web_search, false); assert.equal(parsedBody.features.auto_web_search, true); assert.equal(parsedBody.features.enable_thinking, true); - assert.equal("reasoning_effort" in parsedBody.features, false); + assert.equal(parsedBody.features.reasoning_effort, "max"); assert.equal(result.headers.Authorization, "Bearer [REDACTED]"); assert.equal(result.transformedBody.captcha_verify_param, "[REDACTED]"); } finally { @@ -516,7 +523,7 @@ describe("ZaiWebExecutor", () => { } }); - it("sends GLM-5.2 Deep Think High through the direct request path", async () => { + it("sends GLM-5.3 Deep Think Low through the direct request path", async () => { const capture: ZaiFetchCapture = {}; const originalFetch = installZaiFetch( () => @@ -529,36 +536,36 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); await executor.execute({ - model: "glm-5.2", + model: "glm-5.3", body: { - model: "glm-5.2", + model: "glm-5.3", messages: [{ role: "user", content: "think carefully" }], - reasoning_effort: "high", + reasoning_effort: "low", }, stream: false, credentials: { apiKey: TEST_CREDENTIAL }, signal: null, }); - // #8014: completions must target the versioned v2 path. The query string + // The query string // carries the per-request signature payload, so match the endpoint prefix. assert.ok( String(capture.completionUrl).startsWith("https://chat.z.ai/api/v2/chat/completions?"), - `expected the v2 completions endpoint, got ${capture.completionUrl}` + `expected the current completions endpoint, got ${capture.completionUrl}` ); const newChatBody = JSON.parse(String(capture.newChatInit?.body)); assert.equal(newChatBody.chat.enable_thinking, true); - assert.equal(newChatBody.chat.reasoning_effort, "high"); + assert.equal(newChatBody.chat.reasoning_effort, "low"); const completionBody = JSON.parse(String(capture.completionInit?.body)); assert.equal(completionBody.features.enable_thinking, true); - assert.equal(completionBody.features.reasoning_effort, "high"); + assert.equal(completionBody.features.reasoning_effort, "low"); } finally { globalThis.fetch = originalFetch; } }); - it("sends GLM-5V-Turbo VLM tools and web-search flags through the direct path", async () => { + it("maps GLM-5.3-Flash to its opaque wire id on the direct path", async () => { const capture: ZaiFetchCapture = {}; const originalFetch = installZaiFetch( () => @@ -571,10 +578,11 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); await executor.execute({ - model: "GLM-5v-Turbo", + model: "glm-5.3-flash", body: { - model: "GLM-5v-Turbo", - messages: [{ role: "user", content: "inspect this image" }], + model: "glm-5.3-flash", + messages: [{ role: "user", content: "answer quickly" }], + web_search: true, }, stream: false, credentials: { apiKey: TEST_CREDENTIAL }, @@ -582,19 +590,26 @@ describe("ZaiWebExecutor", () => { }); const newChatBody = JSON.parse(String(capture.newChatInit?.body)); + assert.deepEqual(newChatBody.chat.models, ["x-preview-l"]); + assert.deepEqual( + newChatBody.chat.history.messages[newChatBody.chat.history.currentId].models, + ["x-preview-l"] + ); assert.equal(newChatBody.chat.enable_thinking, true); assert.equal(newChatBody.chat.auto_web_search, true); - assert.equal(newChatBody.chat.extra.vlm_tools_enable, true); - assert.equal(newChatBody.chat.extra.vlm_web_search_enable, true); - assert.equal(newChatBody.chat.extra.vlm_website_mode, true); + assert.equal(newChatBody.chat.reasoning_effort, "max"); + assert.equal(newChatBody.chat.extra.vlm_tools_enable, false); + assert.equal(newChatBody.chat.extra.vlm_web_search_enable, false); + assert.equal(newChatBody.chat.extra.vlm_website_mode, false); const completionBody = JSON.parse(String(capture.completionInit?.body)); + assert.equal(completionBody.model, "x-preview-l"); assert.equal(completionBody.features.enable_thinking, true); - assert.equal(completionBody.features.auto_web_search, false); - assert.equal(completionBody.features.vlm_tools_enable, true); - assert.equal(completionBody.features.vlm_web_search_enable, true); - assert.equal(completionBody.features.vlm_website_mode, true); - assert.equal("reasoning_effort" in completionBody.features, false); + assert.equal(completionBody.features.reasoning_effort, "max"); + assert.equal(completionBody.features.auto_web_search, true); + assert.equal(completionBody.features.vlm_tools_enable, false); + assert.equal(completionBody.features.vlm_web_search_enable, false); + assert.equal(completionBody.features.vlm_website_mode, false); } finally { globalThis.fetch = originalFetch; } @@ -619,7 +634,7 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: TEST_CREDENTIAL }, @@ -651,7 +666,7 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hi" }] }, stream: true, credentials: { apiKey: TEST_CREDENTIAL }, @@ -673,7 +688,7 @@ describe("ZaiWebExecutor", () => { try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "GLM-5.1", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hi" }] }, stream: false, credentials: { apiKey: TEST_CREDENTIAL }, diff --git a/tests/unit/fixtures/cursor-rewrite-failure-ids.ts b/tests/unit/fixtures/cursor-rewrite-failure-ids.ts index dd064537c6..641d383cb8 100644 --- a/tests/unit/fixtures/cursor-rewrite-failure-ids.ts +++ b/tests/unit/fixtures/cursor-rewrite-failure-ids.ts @@ -4,11 +4,16 @@ * Smoke checklist for catalog-aware pass-through. */ export const CURSOR_REWRITE_FAILURE_IDS = [ - // Claude (52) + // Claude (57) "claude-4.5-opus-high", "claude-4.6-opus-high", "claude-4.6-opus-max", "claude-4.6-sonnet-medium", + "claude-fable-5-1-thinking-low", + "claude-fable-5-1-thinking-medium", + "claude-fable-5-1-thinking-high", + "claude-fable-5-1-thinking-xhigh", + "claude-fable-5-1-thinking-max", "claude-fable-5-low", "claude-fable-5-medium", "claude-fable-5-high", diff --git a/tests/unit/free-models.test.ts b/tests/unit/free-models.test.ts index e857db1bdf..0d4ae6a566 100644 --- a/tests/unit/free-models.test.ts +++ b/tests/unit/free-models.test.ts @@ -57,15 +57,12 @@ test("isFreeModel: a model id listed in the free catalog for that provider is fr assert.equal(isFreeModel(sample.provider, { id: sample.modelId }), true); }); -test("isFreeModel: NVIDIA GLM 5.2 is included in the reviewed trial catalog", () => { - assert.equal(isFreeModel("nvidia", { id: "z-ai/glm-5.2" }), true); +test("isFreeModel: NVIDIA GPT OSS 120B remains in the reviewed trial catalog", () => { + assert.equal(isFreeModel("nvidia", { id: "openai/gpt-oss-120b" }), true); }); test("selectModelsForImport: passthrough when importFreeOnly is false", () => { - const models = [ - { id: "a:free" }, - { id: "b", pricing: { prompt: "0.01", completion: "0.02" } }, - ]; + const models = [{ id: "a:free" }, { id: "b", pricing: { prompt: "0.01", completion: "0.02" } }]; const result = selectModelsForImport("openrouter", models, false); assert.equal(result.models.length, 2); assert.equal(result.freeFilterEmpty, false); @@ -130,8 +127,14 @@ test("sortModelsFreeFirst: deterministic (alphabetical) within each group, regar ], { isFree: (m) => m.isFree, key: (m) => m.id } ); - assert.deepEqual(a.map((m) => m.id), ["a", "b", "c"]); - assert.deepEqual(b.map((m) => m.id), ["a", "b", "c"]); + assert.deepEqual( + a.map((m) => m.id), + ["a", "b", "c"] + ); + assert.deepEqual( + b.map((m) => m.id), + ["a", "b", "c"] + ); }); test("sortModelsFreeFirst: does not mutate the input array", () => { @@ -141,5 +144,8 @@ test("sortModelsFreeFirst: does not mutate the input array", () => { ]; const before = items.map((m) => m.id); sortModelsFreeFirst(items, { isFree: (m) => m.isFree, key: (m) => m.id }); - assert.deepEqual(items.map((m) => m.id), before); + assert.deepEqual( + items.map((m) => m.id), + before + ); }); diff --git a/tests/unit/generic-quota-fetcher.test.ts b/tests/unit/generic-quota-fetcher.test.ts index d8edbd9dae..6edce4894f 100644 --- a/tests/unit/generic-quota-fetcher.test.ts +++ b/tests/unit/generic-quota-fetcher.test.ts @@ -4,9 +4,36 @@ import assert from "node:assert/strict"; const genericModule = await import("../../open-sse/services/genericQuotaFetcher.ts"); const preflightModule = await import("../../open-sse/services/quotaPreflight.ts"); -const { convertUsageToQuotaInfo, registerGenericQuotaFetchers } = genericModule; +const { + convertUsageToQuotaInfo, + fetchGenericQuota, + invalidateGenericQuotaCache, + invalidateGenericQuotaCacheOnStatus, + registerGenericQuotaFetchers, + __setGenericUsageFetcherForTests, + __agePendingForceRefreshForTests, + __agePendingForceRefreshMissForTests, + __resetGenericQuotaFetcherForTests, +} = genericModule; const { getQuotaFetcher } = preflightModule; +function usageShape(remainingPercentage: number) { + return { + quotas: { + "gemini-3-flash": { + remainingPercentage, + fractionReported: true, + resetAt: "2026-09-01T20:00:00Z", + }, + gemini_models_weekly: { + remainingPercentage, + fractionReported: true, + resetAt: "2026-09-07T00:00:00Z", + }, + }, + }; +} + test("convertUsageToQuotaInfo returns null on null/undefined input", () => { assert.equal(convertUsageToQuotaInfo(null), null); assert.equal(convertUsageToQuotaInfo(undefined), null); @@ -114,3 +141,250 @@ test("registerGenericQuotaFetchers registers Claude, GLM, and OpenCode Go via th // which would couple this test to chat.ts startup wiring. The skip list // semantics are exercised by the source code review. }); + +test.afterEach(() => { + __setGenericUsageFetcherForTests(null); + __resetGenericQuotaFetcherForTests(); +}); + +test("fetchGenericQuota caches a hit inside the 60s window", async () => { + const connectionId = `agy-cache-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(80); + }); + + const connection = { provider: "agy", id: connectionId }; + const first = await fetchGenericQuota(connectionId, connection); + const second = await fetchGenericQuota(connectionId, connection); + + assert.equal(calls.length, 1, "second fetch must reuse the generic cache"); + assert.equal(first?.percentUsed, 0.2); + assert.deepEqual(second, first); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("invalidateGenericQuotaCache makes the next fetch bypass provider-inner usage caches", async () => { + const connectionId = `agy-invalidate-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + let remaining = 80; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(remaining); + }); + + const connection = { provider: "agy", id: connectionId }; + const first = await fetchGenericQuota(connectionId, connection); + assert.equal(first?.percentUsed, 0.2); + assert.equal(calls[0]?.forceRefresh, undefined); + + remaining = 0; + invalidateGenericQuotaCache("agy", connectionId); + const second = await fetchGenericQuota(connectionId, connection); + + assert.equal(calls.length, 2, "invalidate must drop the 60s generic cache"); + assert.equal( + calls[1]?.forceRefresh, + true, + "agy retrieveUserQuota / weekly caches are 60s–5min; invalidate must force-refresh or the recache is stale" + ); + assert.equal(second?.percentUsed, 1); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("invalidateGenericQuotaCacheOnStatus drops cache on 429 and ignores 200", async () => { + const connectionId = `agy-429-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(50); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 1); + + invalidateGenericQuotaCacheOnStatus({ + provider: "agy", + connectionId, + status: 200, + isolateProbe: false, + }); + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 1, "200 must not drop the generic quota cache"); + + const dropped429 = invalidateGenericQuotaCacheOnStatus({ + provider: "agy", + connectionId, + status: 429, + isolateProbe: false, + }); + assert.equal(dropped429, true); + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "429 must drop the generic quota cache"); + assert.equal(calls[1]?.forceRefresh, true); + + invalidateGenericQuotaCacheOnStatus({ + provider: "agy", + connectionId, + status: 429, + isolateProbe: true, + }); + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "probe-origin 429 must not touch routing caches"); + + assert.doesNotThrow(() => + invalidateGenericQuotaCacheOnStatus({ + provider: "agy", + connectionId: null, + status: 429, + isolateProbe: false, + }) + ); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("invalidateGenericQuotaCacheOnStatus trims to the same key fetchGenericQuota uses", async () => { + const connectionId = `agy-trim-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(40); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(` ${connectionId} `, connection); + assert.equal(calls.length, 1); + + const dropped = invalidateGenericQuotaCacheOnStatus({ + provider: " agy ", + connectionId: ` ${connectionId} `, + status: 429, + isolateProbe: false, + }); + assert.equal(dropped, true); + + await fetchGenericQuota(connectionId, { provider: "agy", id: connectionId }); + assert.equal(calls.length, 2, "padded 429 key must drop the unpadded wrapper cache"); + assert.equal(calls[1]?.forceRefresh, true); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("convert-null after invalidate keeps forceRefresh until a measurable quota recaches", async () => { + const connectionId = `agy-null-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + let n = 0; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + n += 1; + if (n === 1) return usageShape(80); + if (n === 2) return { message: "auth expired" }; + return usageShape(10); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(connectionId, connection); + invalidateGenericQuotaCache("agy", connectionId); + + const second = await fetchGenericQuota(connectionId, connection); + assert.equal(second, null); + assert.equal(calls[1]?.forceRefresh, true); + + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "convert-null must not hammer usage inside 60s"); + + __agePendingForceRefreshMissForTests("agy", connectionId, 60_000 + 1); + const fourth = await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 3); + assert.equal(calls[2]?.forceRefresh, true, "convert-null must not drop the force-refresh flag"); + assert.equal(fourth?.percentUsed, 0.9); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("in-flight fetch must not drop a concurrent 429 force-refresh", async () => { + const connectionId = `agy-race-${Date.now()}`; + let release: (value?: unknown) => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const calls: Array<{ forceRefresh?: boolean }> = []; + let n = 0; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + n += 1; + if (n === 1) { + await gate; + return usageShape(80); + } + return usageShape(10); + }); + + const connection = { provider: "agy", id: connectionId }; + const inflight = fetchGenericQuota(connectionId, connection); + await Promise.resolve(); + invalidateGenericQuotaCache("agy", connectionId); + release(); + const first = await inflight; + assert.equal(first?.percentUsed, 0.2); + + const second = await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "concurrent 429 must not let the in-flight recache wipe force-refresh"); + assert.equal(calls[1]?.forceRefresh, true); + assert.equal(second?.percentUsed, 0.9); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("expired pending force-refresh does not bypass the 60s wrapper cache", async () => { + const connectionId = `agy-expire-${Date.now()}`; + const calls: Array<{ forceRefresh?: boolean }> = []; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + return usageShape(80); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(connectionId, connection); + invalidateGenericQuotaCache("agy", connectionId); + __agePendingForceRefreshForTests("agy", connectionId, 5 * 60_000 + 1); + + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "wrapper cache was dropped; fetch still happens"); + assert.equal( + calls[1]?.forceRefresh, + undefined, + "expired force-refresh must not pass forceRefresh after inner caches have aged out" + ); + invalidateGenericQuotaCache("agy", connectionId); +}); + +test("stamp expiry during in-flight fetch still writes the wrapper cache", async () => { + const connectionId = `agy-stamp-expire-${Date.now()}`; + let release: (value?: unknown) => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + const calls: Array<{ forceRefresh?: boolean }> = []; + let n = 0; + __setGenericUsageFetcherForTests(async (_conn, options) => { + calls.push({ forceRefresh: options?.forceRefresh }); + n += 1; + if (n === 1) return usageShape(50); + await gate; + return usageShape(80); + }); + + const connection = { provider: "agy", id: connectionId }; + await fetchGenericQuota(connectionId, connection); + invalidateGenericQuotaCache("agy", connectionId); + + const inflight = fetchGenericQuota(connectionId, connection); + await Promise.resolve(); + __agePendingForceRefreshForTests("agy", connectionId, 5 * 60_000 + 1); + release(); + await inflight; + + await fetchGenericQuota(connectionId, connection); + assert.equal(calls.length, 2, "expired stamp during await is not a 429; cache the result"); + invalidateGenericQuotaCache("agy", connectionId); +}); diff --git a/tests/unit/glm-executor.test.ts b/tests/unit/glm-executor.test.ts index 4cb31e90f6..26ef4f8526 100644 --- a/tests/unit/glm-executor.test.ts +++ b/tests/unit/glm-executor.test.ts @@ -130,9 +130,9 @@ test("GlmExecutor normalizes GLM coding and Anthropic URLs without duplicating e }); test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic headers", async () => { - assert.equal(await getExecutor("glm") instanceof GlmExecutor, true); - assert.equal(await getExecutor("glm-cn") instanceof GlmExecutor, true); - assert.equal(await getExecutor("glmt") instanceof GlmExecutor, true); + assert.equal((await getExecutor("glm")) instanceof GlmExecutor, true); + assert.equal((await getExecutor("glm-cn")) instanceof GlmExecutor, true); + assert.equal((await getExecutor("glmt")) instanceof GlmExecutor, true); const executor = new GlmExecutor("glm"); const codingHeaders = executor.buildHeaders( @@ -183,7 +183,7 @@ test("GlmExecutor separates OpenAI-compatible coding headers from Anthropic head assert.equal(anthropicHeaders["anthropic-version"], "2023-06-01"); assert.match(anthropicHeaders["anthropic-beta"], /claude-code-20250219/); assert.equal(anthropicHeaders["anthropic-dangerous-direct-browser-access"], "true"); - assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.220 \(external, sdk-cli\)$/); + assert.match(anthropicHeaders["User-Agent"], /^claude-cli\/2\.1\.258 \(external, sdk-cli\)$/); assert.equal(anthropicHeaders["X-Stainless-Lang"], "js"); assert.equal(anthropicHeaders["X-Stainless-Runtime"], "node"); }); diff --git a/tests/unit/grok-cli-provider-limits.test.ts b/tests/unit/grok-cli-provider-limits.test.ts index 081d403f5f..72e7c2b2da 100644 --- a/tests/unit/grok-cli-provider-limits.test.ts +++ b/tests/unit/grok-cli-provider-limits.test.ts @@ -38,6 +38,10 @@ function successFixtures( userId?: unknown; prepaidBalance?: Record | null | undefined; productUsage?: unknown; + creditUsagePercent?: number | null; + omitCreditUsagePercent?: boolean; + omitProductUsage?: boolean; + currentPeriod?: Record | null; } = {} ) { const tier = "tier" in options ? options.tier : "SuperGrok Heavy"; @@ -51,6 +55,14 @@ function successFixtures( { product: "API", usagePercent: 12.5 }, { product: "Grok Code", usagePercent: 44 }, ]; + const currentPeriod = + "currentPeriod" in options + ? options.currentPeriod + : { + type: "WEEKLY", + start: "2026-07-27T00:00:00.000Z", + end: "2026-08-03T00:00:00.000Z", + }; return async (input: string | URL | Request) => { const url = String(input); @@ -64,13 +76,14 @@ function successFixtures( if (url.endsWith("/billing?format=credits")) { return response({ config: { - creditUsagePercent: 37.25, - currentPeriod: { - type: "WEEKLY", - start: "2026-07-27T00:00:00.000Z", - end: "2026-08-03T00:00:00.000Z", - }, - productUsage, + ...(options.omitCreditUsagePercent + ? {} + : { + creditUsagePercent: + "creditUsagePercent" in options ? options.creditUsagePercent : 37.25, + }), + ...(currentPeriod === undefined ? {} : { currentPeriod }), + ...(options.omitProductUsage ? {} : { productUsage }), ...(prepaidBalance === undefined ? {} : { prepaidBalance }), }, }); @@ -492,3 +505,68 @@ test("Provider Limits cache persists only the public Grok billing contract", () test("grok-cli is registered on the public Provider Limits usage seam", () => { assert.ok((USAGE_FETCHER_PROVIDERS as readonly string[]).includes("grok-cli")); }); + +test("SuperGrokPro omitted creditUsagePercent still yields a weekly quota bar", async () => { + const usage = await getUsage( + successFixtures({ + tier: "SuperGrokPro", + omitCreditUsagePercent: true, + omitProductUsage: true, + prepaidBalance: { val: 0 }, + }) as typeof fetch + ); + + assert.equal(usage.plan, "SuperGrokPro"); + assert.deepEqual(usage.quotas?.weekly, { + used: 0, + total: 100, + remaining: 100, + remainingPercentage: 100, + resetAt: "2026-08-03T00:00:00.000Z", + isPercentageOnly: true, + }); + assert.equal(usage.message, undefined); +}); + +test("SuperGrokPro explicit null creditUsagePercent still yields a weekly quota bar", async () => { + const usage = await getUsage( + successFixtures({ + tier: "SuperGrokPro", + creditUsagePercent: null, + omitProductUsage: true, + prepaidBalance: { val: 0 }, + }) as typeof fetch + ); + + assert.equal(usage.plan, "SuperGrokPro"); + assert.deepEqual(usage.quotas?.weekly, { + used: 0, + total: 100, + remaining: 100, + remainingPercentage: 100, + resetAt: "2026-08-03T00:00:00.000Z", + isPercentageOnly: true, + }); +}); + +test("SuperGrokPro omitted currentPeriod still yields a weekly bar with null resetAt", async () => { + const usage = await getUsage( + successFixtures({ + tier: "SuperGrokPro", + omitCreditUsagePercent: true, + omitProductUsage: true, + currentPeriod: null, + prepaidBalance: { val: 0 }, + }) as typeof fetch + ); + + assert.equal(usage.plan, "SuperGrokPro"); + assert.deepEqual(usage.quotas?.weekly, { + used: 0, + total: 100, + remaining: 100, + remainingPercentage: 100, + resetAt: null, + isPercentageOnly: true, + }); +}); diff --git a/tests/unit/guardrails/combo-vision-provider-id-12112.test.ts b/tests/unit/guardrails/combo-vision-provider-id-12112.test.ts index 734e13e2fe..c4ddaf6fe9 100644 --- a/tests/unit/guardrails/combo-vision-provider-id-12112.test.ts +++ b/tests/unit/guardrails/combo-vision-provider-id-12112.test.ts @@ -3,29 +3,27 @@ import assert from "node:assert/strict"; process.env.DATA_DIR = `/tmp/omniroute-test-12112-${Date.now()}`; -const { getComboVisionBridgeDecision } = await import( - "../../../src/lib/guardrails/visionBridge.ts" -); +const { getComboVisionBridgeDecision } = + await import("../../../src/lib/guardrails/visionBridge.ts"); const combosDb = await import("../../../src/lib/db/combos.ts"); const core = await import("../../../src/lib/db/core.ts"); -const { isVisionIncompatibleTarget } = await import( - "../../../open-sse/services/combo/comboStructure.ts" -); +const { isVisionIncompatibleTarget } = + await import("../../../open-sse/services/combo/comboStructure.ts"); import type { ResolvedComboTarget } from "../../../open-sse/services/combo/types.ts"; test.after(() => { core.resetDbInstance(); }); -test("#12112: checkComboVision respects providerId for namespaced vision models (e.g. nvidia/nemotron-nano-12b-v2-vl)", async () => { - // Model 'nvidia/nemotron-nano-12b-v2-vl' is declared with supportsVision: true in nvidia provider registry. +test("#12112: checkComboVision respects providerId for namespaced vision models (e.g. nvidia/nemotron-3-nano-omni-30b-a3b-reasoning)", async () => { + // Model 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning' is declared with supportsVision: true in nvidia provider registry. // It has a slash in model id and requires providerId="nvidia" to resolve capabilities. await combosDb.createCombo({ name: "nvidia-vision-combo-12112", models: [ { providerId: "nvidia", - model: "nvidia/nemotron-nano-12b-v2-vl", + model: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", weight: 1, }, ], @@ -44,7 +42,7 @@ test("#12112: isVisionIncompatibleTarget passes providerId to resolve vision cap kind: "model", stepId: "step-1", executionKey: "step-1", - modelStr: "nvidia/nemotron-nano-12b-v2-vl", + modelStr: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", provider: "nvidia", providerId: "nvidia", connectionId: "conn-1", @@ -64,6 +62,6 @@ test("#12112: isVisionIncompatibleTarget passes providerId to resolve vision cap assert.equal( incompatible, false, - "Target with providerId='nvidia' and model='nvidia/nemotron-nano-12b-v2-vl' must be vision-compatible" + "Target with providerId='nvidia' and model='nvidia/nemotron-3-nano-omni-30b-a3b-reasoning' must be vision-compatible" ); }); diff --git a/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts b/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts new file mode 100644 index 0000000000..8ca1dbab9f --- /dev/null +++ b/tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts @@ -0,0 +1,264 @@ +// #12150 P2 surface 1 (the dominant transcript-retention leak): pure-helper coverage for +// redactVideoTranscriptFieldsForLog — the structured redaction applied to the RAW +// client-request snapshot (clientRawRequest.body) before it is persisted by +// reqLogger.logClientRawRequest (open-sse/handlers/chatCore.ts). See +// src/lib/guardrails/videoBridgeSnapshotRedaction.ts for the full design rationale +// (deliberately dependency-light; field-presence match rather than a shape allowlist). +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + redactVideoTranscriptFieldsForLog, + redactPendingBody, +} from "../../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts"; +// Heavy import is fine here (test only, never in the production module under test) — used +// solely to prove the local placeholder literal never drifts from the canonical P1 constant. +import { VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER } from "../../../src/lib/guardrails/videoBridgeHelpers.ts"; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value as JsonRecord; +} + +function contentAt( + body: unknown, + container: "messages" | "input", + messageIndex: number +): JsonRecord[] { + const messages = asRecord(body)[container] as JsonRecord[]; + return messages[messageIndex].content as JsonRecord[]; +} + +test("redacts transcript and audioTranscript directly on a video part (messages container)", () => { + const body = { + model: "gpt-x", + messages: [ + { role: "system", content: "sys" }, + { + role: "user", + content: [ + { type: "text", text: "look at this video" }, + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: "secret words", startSeconds: 0, endSeconds: 2 }] }, + audioTranscript: { cues: [{ text: "audio secret", startSeconds: 0, endSeconds: 1 }] }, + }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + assert.notEqual(result, body, "must return a new structure, not the same reference"); + + const videoPart = contentAt(result, "messages", 1)[1]; + assert.equal(videoPart.transcript, "[redacted-video-transcript]"); + assert.equal(videoPart.audioTranscript, "[redacted-video-transcript]"); + // The video ref itself and the sibling non-video part must survive untouched. + assert.equal(videoPart.video_url, "https://example.com/clip.mp4"); + assert.equal(contentAt(result, "messages", 1)[0].text, "look at this video"); + assert.equal(asRecord(result).messages, asRecord(result).messages); // sanity: still an array + + const serialized = JSON.stringify(result); + assert.ok(!serialized.includes("secret words"), "raw video transcript must not survive"); + assert.ok(!serialized.includes("audio secret"), "raw audio transcript must not survive"); +}); + +test("redacts a transcript nested under the video_url sub-object", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "video_url", + video_url: { + url: "https://example.com/nested.mp4", + transcript: { cues: [{ text: "nested secret" }] }, + }, + }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + const part = contentAt(result, "messages", 0)[0]; + const videoUrl = part.video_url as JsonRecord; + assert.equal(videoUrl.transcript, "[redacted-video-transcript]"); + assert.equal(videoUrl.url, "https://example.com/nested.mp4"); + assert.ok(!JSON.stringify(result).includes("nested secret")); +}); + +test("redacts a transcript nested under the source sub-object (video_source shape)", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "video_source", + source: { + type: "url", + url: "https://example.com/source.mp4", + audioTranscript: { cues: [{ text: "source secret" }] }, + }, + }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + const part = contentAt(result, "messages", 0)[0]; + const source = part.source as JsonRecord; + assert.equal(source.audioTranscript, "[redacted-video-transcript]"); + assert.equal(source.url, "https://example.com/source.mp4"); + assert.ok(!JSON.stringify(result).includes("source secret")); +}); + +test("covers the input container (Responses API shape)", () => { + const body = { + model: "gpt-x", + input: [ + { + role: "user", + content: [ + { + type: "input_video", + video_url: "https://example.com/input.mp4", + transcript: { cues: [{ text: "input secret" }] }, + }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + const part = contentAt(result, "input", 0)[0]; + assert.equal(part.transcript, "[redacted-video-transcript]"); + assert.ok(!JSON.stringify(result).includes("input secret")); +}); + +test("does not mutate the input", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: "secret words" }] }, + audioTranscript: { cues: [{ text: "audio secret" }] }, + }, + ], + }, + ], + }; + const before = JSON.parse(JSON.stringify(body)); + + redactVideoTranscriptFieldsForLog(body); + + assert.deepEqual(body, before, "input object must be byte-identical after the call"); +}); + +test("a non-video body is returned unchanged", () => { + const body = { + model: "gpt-x", + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "hello, no video here" }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + assert.deepEqual(result, body); +}); + +test("a body with a video part but no transcript field is unchanged", () => { + const body = { + messages: [ + { + role: "user", + content: [{ type: "input_video", video_url: "https://example.com/no-transcript.mp4" }], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + assert.deepEqual(result, body); +}); + +test("the redaction placeholder matches the canonical P1 constant (no drift)", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { type: "input_video", video_url: "https://example.com/clip.mp4", transcript: "raw" }, + ], + }, + ], + }; + + const result = redactVideoTranscriptFieldsForLog(body); + const part = contentAt(result, "messages", 0)[0]; + assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER); +}); + +// #12430 item 6 (P2c): the sibling in-memory leak. `trackPendingRequest` +// (open-sse/handlers/chatCore.ts) stores the same raw client body under +// `clientRequest`, live-exposed via /api/usage/call-logs (pendingDetails), +// /api/logs/[id] and /api/conversations while the request is in-flight. This +// helper is the guarded call-site wrapper chatCore.ts uses, mirroring +// logClientRawRequestRedacted's observed/non-observed branching. +test("redactPendingBody: observed=true delegates to redactVideoTranscriptFieldsForLog", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: "pending secret" }] }, + }, + ], + }, + ], + }; + + const result = redactPendingBody(body, true); + assert.notEqual( + result, + body, + "observed path must return a new structure, not the same reference" + ); + const part = contentAt(result, "messages", 0)[0]; + assert.equal(part.transcript, VIDEO_TRANSCRIPT_REDACTION_PLACEHOLDER); + assert.ok(!JSON.stringify(result).includes("pending secret")); + assert.deepEqual(result, redactVideoTranscriptFieldsForLog(body)); +}); + +test("redactPendingBody: observed=false returns the SAME reference unchanged", () => { + const body = { + messages: [ + { + role: "user", + content: [ + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: "not observed" }] }, + }, + ], + }, + ], + }; + + const result = redactPendingBody(body, false); + assert.equal(result, body, "non-observed path must return the exact same reference"); +}); diff --git a/tests/unit/guardrails/visionBridgeRouter.test.ts b/tests/unit/guardrails/visionBridgeRouter.test.ts index 3197f08e20..dc685a61ff 100644 --- a/tests/unit/guardrails/visionBridgeRouter.test.ts +++ b/tests/unit/guardrails/visionBridgeRouter.test.ts @@ -263,12 +263,12 @@ test("getFallbackModels — excludes fallbacks missing from an authoritative liv test("getFallbackModels — keeps registered effort variants backed by a live base model", async () => { const fallbacks = await getFallbackModels( - "cu/gpt-5.3-codex", + "cu/claude-fable-5-1-thinking-max", { maxFallbackAttempts: 6 }, - authoritativeCatalogDeps("cu", () => ["gpt-5.3-codex"]) + authoritativeCatalogDeps("cu", () => ["claude-fable-5-1"]) ); - assert.ok(fallbacks.includes("cu/gpt-5.3-codex-low")); + assert.ok(fallbacks.includes("cu/claude-fable-5-1-thinking-high")); }); // ── recordLatency / getLatencyStats ───────────────────────────────────────── diff --git a/tests/unit/hard-session-lease-bypass-inventory.test.ts b/tests/unit/hard-session-lease-bypass-inventory.test.ts index 22e6954a76..97e713a0c7 100644 --- a/tests/unit/hard-session-lease-bypass-inventory.test.ts +++ b/tests/unit/hard-session-lease-bypass-inventory.test.ts @@ -84,6 +84,8 @@ const EXPECTED: Record> = { "open-sse/handlers/cursorCliProxy.ts": 1, "open-sse/services/alibabaFreeTier.ts": 1, "open-sse/services/alibabaFreeTierQuotaFetcher.ts": 1, + // Family cooldown persist looks the row up to write PSD, not dispatch. + "open-sse/services/antigravityFamilyCooldown.ts": 1, // v3.8.50 back-merge additions (f95b03d7): combo routing infra and the // volcengine-plan binding/auto-sync services query connections the same // way as their classified siblings. diff --git a/tests/unit/headroom-url-settings-12306.test.ts b/tests/unit/headroom-url-settings-12306.test.ts new file mode 100644 index 0000000000..e9c89c8638 --- /dev/null +++ b/tests/unit/headroom-url-settings-12306.test.ts @@ -0,0 +1,153 @@ +/** + * #12306: settings.headroomUrl must survive PATCH /api/settings. + * + * Status/start already READ settings.headroomUrl. Without the schema + * field Zod strips the key and the write path is a no-op. + */ +import { test, 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-12306-headroom-")); +const originalDataDir = process.env.DATA_DIR; +process.env.DATA_DIR = testDataDir; + +const { updateSettingsSchema } = await import("../../src/shared/validation/settingsSchemas.ts"); +const coreDb = await import("../../src/lib/db/core.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); + +after(() => { + coreDb.resetDbInstance(); + if (fs.existsSync(testDataDir)) { + fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + if (originalDataDir === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = originalDataDir; +}); + +test("updateSettingsSchema keeps a valid headroomUrl", () => { + const parsed = updateSettingsSchema.parse({ + headroomUrl: "http://127.0.0.1:8787", + }); + assert.equal(parsed.headroomUrl, "http://127.0.0.1:8787"); +}); + +test("updateSettingsSchema accepts an empty headroomUrl to fall back to HEADROOM_URL", () => { + const parsed = updateSettingsSchema.parse({ headroomUrl: "" }); + assert.equal(parsed.headroomUrl, ""); +}); + +test("updateSettingsSchema trims a padded headroomUrl before validating", () => { + const parsed = updateSettingsSchema.parse({ + headroomUrl: " http://headroom.internal:9090 ", + }); + assert.equal(parsed.headroomUrl, "http://headroom.internal:9090"); +}); + +test("updateSettingsSchema treats whitespace-only headroomUrl as empty", () => { + const parsed = updateSettingsSchema.parse({ headroomUrl: " " }); + assert.equal(parsed.headroomUrl, ""); +}); + +test("updateSettingsSchema rejects a non-URL headroomUrl", () => { + const result = updateSettingsSchema.safeParse({ headroomUrl: "not-a-url" }); + assert.equal(result.success, false); +}); + +test("updateSettingsSchema rejects non-http(s) headroomUrl schemes", () => { + for (const url of [ + "javascript:alert(1)", + "ftp://x", + "data:text/html,x", + "file:///etc/passwd", + "http://", + "http://[", + "http:", + ]) { + const result = updateSettingsSchema.safeParse({ headroomUrl: url }); + assert.equal(result.success, false, url); + } +}); + +test("updateSettingsSchema rejects a headroomUrl over 500 chars", () => { + const result = updateSettingsSchema.safeParse({ + headroomUrl: `http://example.com/${"x".repeat(500)}`, + }); + assert.equal(result.success, false); +}); + +test("updateSettings round-trips a validated headroomUrl", async () => { + await coreDb.ensureDbInitialized(); + const parsed = updateSettingsSchema.parse({ + headroomUrl: "http://headroom.internal:9090", + }); + await settingsDb.updateSettings(parsed); + const stored = await settingsDb.getSettings(); + assert.equal(stored.headroomUrl, "http://headroom.internal:9090"); +}); + +test("updateSettings round-trips an empty headroomUrl without dropping the key", async () => { + await coreDb.ensureDbInitialized(); + await settingsDb.updateSettings( + updateSettingsSchema.parse({ headroomUrl: "http://headroom.internal:9090" }) + ); + await settingsDb.updateSettings(updateSettingsSchema.parse({ headroomUrl: "" })); + const stored = await settingsDb.getSettings(); + assert.equal(stored.headroomUrl, ""); +}); + +test("advanced settings page mounts the Headroom proxy card", async () => { + const src = fs.readFileSync( + path.join(import.meta.dirname, "../../src/app/(dashboard)/dashboard/settings/advanced/page.tsx"), + "utf8" + ); + assert.match(src, /HeadroomProxyCard/); +}); + +test("after() restores DATA_DIR so later files in the same process keep their own dir", () => { + const src = fs.readFileSync(new URL(import.meta.url), "utf8"); + assert.match(src, /const originalDataDir = process\.env\.DATA_DIR/); + assert.match(src, /if \(originalDataDir === undefined\) delete process\.env\.DATA_DIR/); +}); + +test("save reads PATCH validation details instead of a generic HTTP status", () => { + const src = fs.readFileSync( + path.join( + import.meta.dirname, + "../../src/app/(dashboard)/dashboard/settings/components/HeadroomProxyCard.tsx" + ), + "utf8" + ); + assert.match(src, /throw new Error\(settingsErrorText\(body, `HTTP \$\{res\.status\}`\)\)/); + assert.equal( + (src.match(/throw new Error\(settingsErrorText\(body, `HTTP \$\{res\.status\}`\)\)/g) || []).length, + 2 + ); + assert.match(src, /body\.error\?\.details/); + assert.match(src, /isHttpUrl/); + assert.match(src, /const HEADROOM_URL_MAX = 500/); + assert.match(src, /setUrl\(trimmed\)/); + assert.match(src, /const saveAc = useRef\(null\)/); + assert.match(src, /const lifecycleAc = useRef\(null\)/); + assert.match(src, /saveAc\.current = ac/); + assert.match(src, /lifecycleAc\.current = ac/); + assert.match(src, /await fetch\(path, \{ method: "POST", signal \}\)/); + assert.match(src, /body: JSON.stringify\(\{ headroomUrl: trimmed \}\),\s*signal,/s); + assert.match(src, /\/\/ start\/stop already succeeded; status is best-effort\./); + // Busy flags: clear only if this invocation still owns the controller. + // A second click replaces the ref; the first finally must not unlock. + assert.match(src, /if \(saveAc\.current === ac\) setSaving\(false\)/); + assert.match(src, /if \(lifecycleAc\.current === ac\) setActing\(false\)/); + assert.doesNotMatch(src, /if \(!signal\.aborted\) setSaving\(false\)/); + assert.doesNotMatch(src, /if \(!signal\.aborted\) setActing\(false\)/); + assert.match( + src, + /return \(\) => \{\s*ac\.abort\(\);\s*saveAc\.current\?\.abort\(\);\s*lifecycleAc\.current\?\.abort\(\);/s + ); + assert.match(src, /const busy = saving \|\| acting;/); + assert.match(src, /disabled=\{busy\}/); + assert.match(src, /disabled=\{busy \|\| !canStart\}/); + assert.match(src, /disabled=\{busy \|\| !running\}/); +}); diff --git a/tests/unit/live-model-catalog-reconciliation-8926.test.ts b/tests/unit/live-model-catalog-reconciliation-8926.test.ts index 4bd415dee3..5bc1d15c54 100644 --- a/tests/unit/live-model-catalog-reconciliation-8926.test.ts +++ b/tests/unit/live-model-catalog-reconciliation-8926.test.ts @@ -117,24 +117,24 @@ test("#8926: explicit custom model overrides live-catalog exclusion", async () = }); test("#8926: effort helper identifies only explicitly registered variants", () => { - assert.equal(isRegisteredProviderEffortVariant("cursor", "gpt-5.3-codex-high"), true); + assert.equal(isRegisteredProviderEffortVariant("cursor", "claude-fable-5-1-thinking-high"), true); assert.equal( - isRegisteredProviderEffortVariant("cursor", "gpt-5.3-codex-max"), + isRegisteredProviderEffortVariant("cursor", "claude-fable-5-1-thinking-ultra"), false, "an invented suffix must not bypass live-catalog authority" ); }); test("#8926: registered effort route survives while invented effort route is rejected", async () => { - await seedProviderCatalog("cursor", "cursor-live-8926", ["gpt-5.3-codex"]); + await seedProviderCatalog("cursor", "cursor-live-8926", ["claude-fable-5-1"]); - const registered = await getModelInfo("cursor/gpt-5.3-codex-high"); + const registered = await getModelInfo("cursor/claude-fable-5-1-thinking-high"); assert.equal(registered.provider, "cursor"); - assert.equal(registered.model, "gpt-5.3-codex-high"); + assert.equal(registered.model, "claude-fable-5-1-thinking-high"); - const invented = await getModelInfo("cursor/gpt-5.3-codex-max"); + const invented = await getModelInfo("cursor/claude-fable-5-1-thinking-ultra"); assert.equal(invented.provider, null); assert.equal(invented.errorType, "model_not_found"); @@ -170,13 +170,13 @@ test("#8926: providers without an authoritative live catalog retain static fallb test("#8926: registered effort variant is rejected when its live base is absent", async () => { await seedProviderCatalog("cursor", "cursor-live-without-base-8926", ["cursor-live-only-8926"]); - const explicit = await getModelInfo("cursor/gpt-5.3-codex-high"); + const explicit = await getModelInfo("cursor/claude-fable-5-1-thinking-high"); assert.equal(explicit.provider, null); assert.equal(explicit.errorType, "model_not_found"); assert.match(explicit.errorMessage, /active live catalog/i); - const bare = await getModelInfo("gpt-5.3-codex-high"); + const bare = await getModelInfo("claude-fable-5-1-thinking-high"); assert.equal(bare.provider, null); assert.equal(bare.errorType, "model_not_found"); diff --git a/tests/unit/model-capabilities-registry.test.ts b/tests/unit/model-capabilities-registry.test.ts index 7bb82681db..98adbbf21a 100644 --- a/tests/unit/model-capabilities-registry.test.ts +++ b/tests/unit/model-capabilities-registry.test.ts @@ -203,7 +203,7 @@ test("GPT OSS and DeepSeek Reasoner models support tool calling", () => { // GPT OSS models should not be blocked by the heuristic assert.equal(modelCapabilities.supportsToolCalling("fake-provider/gpt-oss-120b"), true); assert.equal(modelCapabilities.supportsToolCalling("gpt-oss-120b"), true); - assert.equal(modelCapabilities.supportsToolCalling("nvidia/openai/gpt-oss-20b"), false); // in registry + assert.equal(modelCapabilities.supportsToolCalling("nvidia/openai/gpt-oss-120b"), false); // in registry // DeepSeek Reasoner supports tool calling assert.equal(modelCapabilities.supportsToolCalling("deepseek-reasoner"), true); diff --git a/tests/unit/model-test-runner.test.ts b/tests/unit/model-test-runner.test.ts index 7a19e67482..c717ea0bb0 100644 --- a/tests/unit/model-test-runner.test.ts +++ b/tests/unit/model-test-runner.test.ts @@ -314,7 +314,7 @@ test("resolveModelTestTimeoutMs defaults ordinary model checks to 30 seconds", ( test("resolveModelTestTimeoutMs gives zai-web checks up to 60 seconds", () => { assert.equal(resolveModelTestTimeoutMs("zai-web", "glm-5.2", 30_000), 60_000); - assert.equal(resolveModelTestTimeoutMs("zai-web", "zai-web/GLM-5V-Turbo", 90_000), 90_000); + assert.equal(resolveModelTestTimeoutMs("zai-web", "zai-web/glm-5.3-flash", 90_000), 90_000); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/moonshot-open-platform.test.ts b/tests/unit/moonshot-open-platform.test.ts new file mode 100644 index 0000000000..c44e1d4859 --- /dev/null +++ b/tests/unit/moonshot-open-platform.test.ts @@ -0,0 +1,72 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + parseMoonshotOrigin, + moonshotBalanceUrl, + resolveMoonshotOrigin, + isMoonshotOpenPlatformConnection, +} from "../../open-sse/services/usage/moonshotOpenPlatform.ts"; + +const CN = "https://api.moonshot.cn/v1"; +const AI = "https://api.moonshot.ai/v1"; +const COMPAT = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; + +test("parseMoonshotOrigin accepts cn and ai hosts only", () => { + assert.equal(parseMoonshotOrigin(CN), "https://api.moonshot.cn"); + assert.equal( + parseMoonshotOrigin("https://api.moonshot.cn/v1/chat/completions"), + "https://api.moonshot.cn", + ); + assert.equal(parseMoonshotOrigin(AI), "https://api.moonshot.ai"); + assert.equal(parseMoonshotOrigin("https://api.openai.com/v1"), null); + assert.equal(parseMoonshotOrigin("https://api.kimi.com/coding/v1"), null); + assert.equal(parseMoonshotOrigin(""), null); + assert.equal(parseMoonshotOrigin(null), null); +}); + +test("moonshotBalanceUrl stays on the connection origin", () => { + assert.equal( + moonshotBalanceUrl("https://api.moonshot.cn"), + "https://api.moonshot.cn/v1/users/me/balance", + ); + assert.equal( + moonshotBalanceUrl("https://api.moonshot.ai"), + "https://api.moonshot.ai/v1/users/me/balance", + ); +}); + +test("resolveMoonshotOrigin prefers psd.baseUrl over node", () => { + const origin = resolveMoonshotOrigin( + { + provider: COMPAT, + providerSpecificData: { baseUrl: CN }, + }, + "https://api.moonshot.ai/v1", + ); + assert.equal(origin, "https://api.moonshot.cn"); +}); + +test("resolveMoonshotOrigin uses node baseUrl when psd has none", () => { + const origin = resolveMoonshotOrigin({ provider: COMPAT, providerSpecificData: {} }, CN); + assert.equal(origin, "https://api.moonshot.cn"); +}); + +test("resolveMoonshotOrigin uses built-in moonshot/kimi registry host", () => { + assert.equal(resolveMoonshotOrigin({ provider: "moonshot" }), "https://api.moonshot.ai"); + assert.equal(resolveMoonshotOrigin({ provider: "kimi" }), "https://api.moonshot.ai"); +}); + +test("isMoonshotOpenPlatformConnection is true for mnative-shaped rows", () => { + assert.equal( + isMoonshotOpenPlatformConnection({ + provider: COMPAT, + providerSpecificData: { baseUrl: CN, prefix: "mnative" }, + }), + true, + ); + assert.equal( + isMoonshotOpenPlatformConnection({ provider: "deepseek", providerSpecificData: {} }), + false, + ); + assert.equal(isMoonshotOpenPlatformConnection({ provider: "moonshot" }), true); +}); diff --git a/tests/unit/moonshot-quota-fetcher.test.ts b/tests/unit/moonshot-quota-fetcher.test.ts new file mode 100644 index 0000000000..1c315632b9 --- /dev/null +++ b/tests/unit/moonshot-quota-fetcher.test.ts @@ -0,0 +1,158 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + fetchMoonshotQuota, + invalidateMoonshotQuotaCache, + registerMoonshotQuotaFetcher, + getMoonshotOpenPlatformUsage, +} from "../../open-sse/services/moonshotQuotaFetcher.ts"; +import { getUsageForProvider } from "../../open-sse/services/usage.ts"; +import { getQuotaFetcher } from "../../open-sse/services/quotaPreflight.ts"; + +const originalFetch = globalThis.fetch; +const CN = "https://api.moonshot.cn/v1"; +const COMPAT = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +test("fetchMoonshotQuota parses available_balance > 0 as not exhausted", async () => { + const connectionId = `ms-pos-${Date.now()}`; + globalThis.fetch = async (url) => { + assert.equal(String(url), "https://api.moonshot.cn/v1/users/me/balance"); + return jsonResponse({ + code: 0, + data: { available_balance: 2.5, voucher_balance: 0, cash_balance: 2.5 }, + status: true, + }); + }; + const q = await fetchMoonshotQuota(connectionId, { + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(q?.limitReached, false); + assert.equal(q?.percentUsed, 0); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("fetchMoonshotQuota treats available_balance 0 as exhausted", async () => { + const connectionId = `ms-zero-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 0, voucher_balance: 0, cash_balance: 0 }, + status: true, + }); + const q = await fetchMoonshotQuota(connectionId, { + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(q?.limitReached, true); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("fetchMoonshotQuota returns null on 401", async () => { + const connectionId = `ms-401-${Date.now()}`; + globalThis.fetch = async () => new Response(null, { status: 401 }); + const q = await fetchMoonshotQuota(connectionId, { + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(q, null); +}); + +test("getUsageForProvider on custom uuid hits Moonshot path", async () => { + const connectionId = `ms-uuid-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 15, voucher_balance: 15, cash_balance: 0 }, + status: true, + }); + const usage = await getUsageForProvider({ + id: connectionId, + provider: COMPAT, + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(typeof usage === "object" && usage && "message" in usage, false); + assert.equal((usage as { plan?: string }).plan, "Kimi 开放平台(国内)"); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("getMoonshotOpenPlatformUsage uses Open Platform plan label for .ai host", async () => { + const connectionId = `ms-ai-${Date.now()}`; + globalThis.fetch = async (url) => { + assert.equal(String(url), "https://api.moonshot.ai/v1/users/me/balance"); + return jsonResponse({ + code: 0, + data: { available_balance: 1, voucher_balance: 0, cash_balance: 1 }, + status: true, + }); + }; + const usage = await getMoonshotOpenPlatformUsage({ + id: connectionId, + provider: "moonshot", + apiKey: "sk-test", + providerSpecificData: { baseUrl: "https://api.moonshot.ai/v1" }, + }); + assert.equal(usage.plan, "Kimi Open Platform"); + invalidateMoonshotQuotaCache(connectionId); +}); + +test("domestic Moonshot balance is labeled CNY, international USD", async () => { + const cnId = `ms-cny-${Date.now()}`; + globalThis.fetch = async () => + jsonResponse({ + code: 0, + data: { available_balance: 15, voucher_balance: 15, cash_balance: 0 }, + status: true, + }); + const cn = await getMoonshotOpenPlatformUsage({ + id: cnId, + provider: COMPAT, + apiKey: "sk-test", + providerSpecificData: { baseUrl: CN }, + }); + assert.equal(cn.quotas?.available?.currency, "CNY"); + invalidateMoonshotQuotaCache(cnId); + + const aiId = `ms-usd-${Date.now()}`; + const ai = await getMoonshotOpenPlatformUsage({ + id: aiId, + provider: "moonshot", + apiKey: "sk-test", + providerSpecificData: { baseUrl: "https://api.moonshot.ai/v1" }, + }); + assert.equal(ai.quotas?.available?.currency, "USD"); + invalidateMoonshotQuotaCache(aiId); +}); + +test("registerMoonshotQuotaFetcher wires moonshot and kimi ids", () => { + registerMoonshotQuotaFetcher(); + assert.equal(typeof getQuotaFetcher("moonshot"), "function"); + assert.equal(typeof getQuotaFetcher("kimi"), "function"); +}); + +test("registerMoonshotFetchersForNodes registers custom node id and prefix", async () => { + const { registerMoonshotFetchersForNodes } = await import( + "../../open-sse/services/moonshotQuotaFetcher.ts" + ); + const uuid = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; + registerMoonshotFetchersForNodes([ + { id: uuid, prefix: "mnative", baseUrl: CN }, + { id: "other", prefix: "oc-prod", baseUrl: "https://api.openai.com/v1" }, + ]); + assert.equal(typeof getQuotaFetcher(uuid), "function"); + assert.equal(typeof getQuotaFetcher("mnative"), "function"); + assert.equal(getQuotaFetcher("oc-prod"), undefined); +}); diff --git a/tests/unit/moonshot-quota-writeback.test.ts b/tests/unit/moonshot-quota-writeback.test.ts new file mode 100644 index 0000000000..14c8e3168b --- /dev/null +++ b/tests/unit/moonshot-quota-writeback.test.ts @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { classify429 } from "../../src/shared/utils/classify429.ts"; +import { checkFallbackError } from "../../open-sse/services/accountFallback.ts"; +import { registerMoonshotFetchersForNodes } from "../../open-sse/services/moonshotQuotaFetcher.ts"; +import { getQuotaFetcher } from "../../open-sse/services/quotaPreflight.ts"; + +const MOONSHOT_TPD = + "Your account org-73b383ab6d45484eb2ef72161074495c / proj-30863601b47548bdbbabc42ff4c72eee request reached organization TPD rate limit, current: 1537190, limit: 1500000"; +const MOONSHOT_BROKE = "insufficient balance"; +const COMPAT = "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf"; + +test("classify429 maps Moonshot TPD to quota_exhausted so combo persist stays on", () => { + assert.equal(classify429({ status: 429, body: MOONSHOT_TPD }), "quota_exhausted"); +}); + +test("checkFallbackError TPD with node clock returns future cooldown, not host midnight", () => { + const now = Date.parse("2026-09-02T07:30:00Z"); + const result = checkFallbackError( + 429, + MOONSHOT_TPD, + 0, + "kimi-k2.5", + COMPAT, + null, + null, + null, + null, + { timezone: "Asia/Shanghai", hour: 0, nowMs: now }, + ); + assert.equal(result.shouldFallback, true); + assert.equal(result.dailyQuotaExhausted, true); + assert.ok(result.cooldownMs > 8 * 3600_000); + const until = now + result.cooldownMs; + assert.ok(Math.abs(until - Date.parse("2026-09-02T16:00:00Z")) < 60_000); +}); + +test("checkFallbackError insufficient balance on compatible node is creditsExhausted", () => { + const result = checkFallbackError(429, MOONSHOT_BROKE, 0, "kimi-k2.5", COMPAT); + assert.equal(result.creditsExhausted, true); + assert.equal(result.shouldFallback, true); +}); + +test("registerMoonshotFetchersForNodes wires uuid and prefix after startup scan", () => { + registerMoonshotFetchersForNodes([ + { id: COMPAT, prefix: "mnative", baseUrl: "https://api.moonshot.cn/v1" }, + ]); + assert.equal(typeof getQuotaFetcher(COMPAT), "function"); + assert.equal(typeof getQuotaFetcher("mnative"), "function"); +}); diff --git a/tests/unit/nvidia-410-model-scope.test.ts b/tests/unit/nvidia-410-model-scope.test.ts index 0d72fe1345..9865e1f5f8 100644 --- a/tests/unit/nvidia-410-model-scope.test.ts +++ b/tests/unit/nvidia-410-model-scope.test.ts @@ -15,7 +15,7 @@ const auth = await import("../../src/sse/services/auth.ts"); const fallback = await import("../../open-sse/services/accountFallback.ts"); const DEAD_MODEL = "deepseek-ai/deepseek-v4-pro"; -const HEALTHY_MODEL = "z-ai/glm-5.2"; +const HEALTHY_MODEL = "nvidia/nemotron-3.5-lightning-30b-a3b"; const GONE_BODY = JSON.stringify({ type: "about:blank", diff --git a/tests/unit/nvidia-eol-catalog.test.ts b/tests/unit/nvidia-eol-catalog.test.ts index ea8708d19e..f5319d08e0 100644 --- a/tests/unit/nvidia-eol-catalog.test.ts +++ b/tests/unit/nvidia-eol-catalog.test.ts @@ -13,20 +13,15 @@ const documentedFreeIds = new Set( const reviewedIds = new Set(reviewedLiveIds); -test("NVIDIA registry excludes retired DeepSeek V4 models", () => { - assert.ok( - !registryIds.has("deepseek-ai/deepseek-v4-pro"), - "retired deepseek-ai/deepseek-v4-pro must not be advertised" - ); - - assert.ok( - !registryIds.has("deepseek-ai/deepseek-v4-flash"), - "retired deepseek-ai/deepseek-v4-flash must not be advertised" - ); -}); - -test("NVIDIA static lifecycle metadata excludes known EOL models", () => { - for (const modelId of ["z-ai/glm-5.1", "deepseek-ai/deepseek-v4-pro"]) { +test("NVIDIA static catalog metadata excludes superseded model ids", () => { + for (const modelId of [ + "z-ai/glm-5.1", + "z-ai/glm-5.2", + "deepseek-ai/deepseek-v4-pro", + "deepseek-ai/deepseek-v4-flash", + "minimaxai/minimax-m2.7", + ]) { + assert.ok(!registryIds.has(modelId), `${modelId} must not remain in the NVIDIA registry`); assert.ok( !reviewedIds.has(modelId), `${modelId} must not remain in the reviewed NVIDIA hosted-model snapshot` @@ -39,16 +34,9 @@ test("NVIDIA static lifecycle metadata excludes known EOL models", () => { } }); -test("NVIDIA cleanup preserves the healthy GLM replacement", () => { - assert.ok(registryIds.has("z-ai/glm-5.2"), "z-ai/glm-5.2 must remain in the NVIDIA registry"); - - assert.ok( - reviewedIds.has("z-ai/glm-5.2"), - "z-ai/glm-5.2 must remain in the reviewed NVIDIA hosted-model snapshot" - ); - - assert.ok( - documentedFreeIds.has("z-ai/glm-5.2"), - "z-ai/glm-5.2 must remain in the NVIDIA free-model catalog" - ); +test("NVIDIA reviewed snapshot matches the registry and trial entries remain valid", () => { + assert.deepEqual([...reviewedIds], [...registryIds]); + for (const modelId of documentedFreeIds) { + assert.ok(registryIds.has(modelId), `${modelId} must exist in the NVIDIA hosted catalog`); + } }); diff --git a/tests/unit/nvidia-minimax-m3-removed-3329.test.ts b/tests/unit/nvidia-minimax-m3-removed-3329.test.ts index 77e0ce4822..f0c8b6d502 100644 --- a/tests/unit/nvidia-minimax-m3-removed-3329.test.ts +++ b/tests/unit/nvidia-minimax-m3-removed-3329.test.ts @@ -4,16 +4,16 @@ import assert from "node:assert/strict"; const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); // #3329: `minimaxai/minimax-m3` was registered in the nvidia (NVIDIA NIM) tier, -// but NVIDIA NIM does not host it — every request returns `404 page not found`, -// while sibling models on the same provider (e.g. `minimaxai/minimax-m2.7`) -// work. Advertising a model that 404s is a catalog bug; it is removed from the -// nvidia tier until NVIDIA actually serves it. It remains on the tiers that do -// (minimax / minimax-cn / opencode / etc.). +// but NVIDIA NIM does not host it — every request returns `404 page not found`. +// Advertising a model that 404s is a catalog bug; it stays absent from the +// NVIDIA tier while remaining available from providers that actually serve it. test("nvidia tier does not advertise minimaxai/minimax-m3 (404 upstream) (#3329)", () => { const nvidia = getRegistryEntry("nvidia"); assert.ok(nvidia, "nvidia registry entry must exist"); const ids = (nvidia.models ?? []).map((m) => m.id); assert.ok(!ids.includes("minimaxai/minimax-m3"), "minimaxai/minimax-m3 must not be in nvidia"); - // sanity: the working sibling stays listed - assert.ok(ids.includes("minimaxai/minimax-m2.7"), "minimaxai/minimax-m2.7 stays available"); + assert.ok( + !ids.includes("minimaxai/minimax-m2.7"), + "removed minimaxai/minimax-m2.7 must stay out" + ); }); diff --git a/tests/unit/nvidia-nim-catalog-expansion-2373.test.ts b/tests/unit/nvidia-nim-catalog-expansion-2373.test.ts deleted file mode 100644 index 32d14a8a00..0000000000 --- a/tests/unit/nvidia-nim-catalog-expansion-2373.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; - -import { nvidiaProvider } from "../../open-sse/config/providers/registry/nvidia/index.ts"; - -// Port of decolua/9router#2373 ("fix(nvidia): expand NIM chat model catalog"). Upstream's -// PR also added a per-model `thinkingFormat`/`kind` capability shape in a legacy -// open-sse/providers/capabilities.js file that has no equivalent in OmniRoute — reasoning -// translation here is per-PROVIDER (open-sse/translator/paramSupport.ts, -// executors/default.ts, both gated on `this.provider === "nvidia"`), not per-model, so -// only the catalog (RegistryModel.supportsReasoning/supportsVision) needed porting. -// Embedding/ASR/TTS entries from the same upstream PR are already covered by -// open-sse/config/embeddingRegistry.ts and audioRegistry.ts, so they are not duplicated -// here. `minimaxai/minimax-m3` is intentionally excluded — see the #3329 guard -// (nvidia-minimax-m3-removed-3329.test.ts). -const modelIds = new Set(nvidiaProvider.models.map((m) => m.id)); - -test("#2373: NVIDIA NIM registry gains the newly-observed chat-completions models", () => { - for (const id of [ - "abacusai/dracarys-llama-3.1-70b-instruct", - "google/gemma-2-2b-it", - "google/gemma-3n-e2b-it", - "meta/llama-3.1-8b-instruct", - "meta/llama-3.2-11b-vision-instruct", - "meta/llama-4-maverick-17b-128e-instruct", - "meta/llama-guard-4-12b", - "mistralai/ministral-14b-instruct-2512", - "mistralai/mistral-medium-3.5-128b", - "mistralai/mistral-nemotron", - "mistralai/mixtral-8x7b-instruct-v0.1", - "nvidia/ising-calibration-1-35b-a3b", - "nvidia/llama-3.1-nemoguard-8b-content-safety", - "nvidia/llama-3.3-nemotron-super-49b-v1.5", - "nvidia/nemotron-3-nano-30b-a3b", - "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", - "nvidia/nemotron-nano-12b-v2-vl", - "nvidia/nvidia-nemotron-nano-9b-v2", - "qwen/qwen3-next-80b-a3b-instruct", - "sarvamai/sarvam-m", - "stockmark/stockmark-2-100b-instruct", - "upstage/solar-10.7b-instruct", - ]) { - assert.ok(modelIds.has(id), `expected nvidia registry to include ${id}`); - } -}); - -test("#2373: reasoning-capable NVIDIA-hosted models are flagged supportsReasoning", () => { - const reasoningIds = [ - "nvidia/ising-calibration-1-35b-a3b", - "nvidia/nemotron-3-nano-30b-a3b", - "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", - "nvidia/nemotron-nano-12b-v2-vl", - "nvidia/nvidia-nemotron-nano-9b-v2", - "qwen/qwen3-next-80b-a3b-instruct", - ]; - for (const id of reasoningIds) { - const model = nvidiaProvider.models.find((m) => m.id === id); - assert.ok(model, `model ${id} must exist`); - assert.equal(model?.supportsReasoning, true, `${id} must be supportsReasoning: true`); - } -}); - -test("#2373/#3329: minimaxai/minimax-m3 stays excluded from the nvidia tier", () => { - assert.ok( - !modelIds.has("minimaxai/minimax-m3"), - "minimaxai/minimax-m3 must not be re-added to the nvidia registry (404 upstream, #3329)" - ); - // sanity: the working sibling stays listed - assert.ok(modelIds.has("minimaxai/minimax-m2.7"), "minimaxai/minimax-m2.7 stays available"); -}); - -test("#2373: non-chat model kinds (NER/diffusion) from the upstream PR are not ported into the chat registry", () => { - assert.ok( - !modelIds.has("nvidia/gliner-pii"), - "nvidia/gliner-pii is an NER/PII tagger, not a chat-completions model" - ); - assert.ok( - !modelIds.has("google/diffusiongemma-26b-a4b-it"), - "google/diffusiongemma-26b-a4b-it is a diffusion model, not a chat-completions model" - ); -}); diff --git a/tests/unit/nvidia-nim-registry-6108.test.ts b/tests/unit/nvidia-nim-registry-6108.test.ts index b9c12d9992..702747656d 100644 --- a/tests/unit/nvidia-nim-registry-6108.test.ts +++ b/tests/unit/nvidia-nim-registry-6108.test.ts @@ -3,21 +3,45 @@ import assert from "node:assert/strict"; import { nvidiaProvider } from "../../open-sse/config/providers/registry/nvidia/index.ts"; -// Regression guard for #6108: the static NVIDIA NIM model registry had gone -// stale — z-ai/glm-5.1 was EOL'd (410) 2026-07-02, while glm-5.2 and -// nvidia/nemotron-3-ultra-550b-a55b were absent. minimaxai/minimax-m3 stays -// excluded per the #3329 guard (nvidia-minimax-m3-removed-3329.test.ts) — the -// single 200 probe in #6108 wasn't reproducible enough to override it. -const modelIds = new Set(nvidiaProvider.models.map((m) => m.id)); +const EXPECTED_MODEL_IDS = [ + "moonshotai/kimi-k3", + "deepseek-ai/deepseek-v4-pro-0813", + "deepseek-ai/deepseek-v4-flash-0731", + "meta/muse-glimmer-30b", + "poolside/laguna-xs-2.1", + "google/gemma-4-31b-it", + "google/diffusiongemma-26b-a4b-it", + "nvidia/nemotron-3-ultra-550b-a55b", + "nvidia/nemotron-3-super-120b-a12b", + "nvidia/nemotron-3.5-lightning-30b-a3b", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + "openai/gpt-oss-120b", +] as const; -test("#6108: NVIDIA NIM registry contains the refreshed live models", () => { - assert.ok(modelIds.has("z-ai/glm-5.2"), "z-ai/glm-5.2 must be present"); - assert.ok( - modelIds.has("nvidia/nemotron-3-ultra-550b-a55b"), - "nvidia/nemotron-3-ultra-550b-a55b must be present" +test("NVIDIA NIM registry exactly matches the current hosted-model catalog", () => { + assert.deepEqual( + nvidiaProvider.models.map((model) => model.id), + EXPECTED_MODEL_IDS ); }); -test("#6108: NVIDIA NIM registry no longer lists EOL z-ai/glm-5.1", () => { - assert.ok(!modelIds.has("z-ai/glm-5.1"), "EOL z-ai/glm-5.1 must be removed"); +test("NVIDIA NIM registry preserves known model capabilities", () => { + const byId = new Map(nvidiaProvider.models.map((model) => [model.id, model])); + + for (const id of [ + "deepseek-ai/deepseek-v4-pro-0813", + "deepseek-ai/deepseek-v4-flash-0731", + "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + ]) { + assert.equal(byId.get(id)?.supportsReasoning, true, `${id} must support reasoning`); + } + + const omni = byId.get("nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"); + assert.equal(omni?.supportsVision, true, "Nemotron 3 Nano Omni must support vision"); + + assert.equal( + byId.get("openai/gpt-oss-120b")?.toolCalling, + false, + "openai/gpt-oss-120b must keep tool calling disabled" + ); }); diff --git a/tests/unit/nvidia-nim-validator.test.ts b/tests/unit/nvidia-nim-validator.test.ts index f6f82e44f6..b6c2c5518d 100644 --- a/tests/unit/nvidia-nim-validator.test.ts +++ b/tests/unit/nvidia-nim-validator.test.ts @@ -135,7 +135,7 @@ test("nvidia specialty validator falls back to stable chat validation model", as calls.some((u) => u.endsWith("/chat/completions")), `should fall back to /chat/completions, called: ${JSON.stringify(calls)}` ); - assert.equal(payload?.model, "meta/llama-3.1-8b-instruct"); + assert.equal(payload?.model, "nvidia/nemotron-3.5-lightning-30b-a3b"); } ); }); diff --git a/tests/unit/nvidia-passthrough-models-6773.test.ts b/tests/unit/nvidia-passthrough-models-6773.test.ts index 915b541413..9275367845 100644 --- a/tests/unit/nvidia-passthrough-models-6773.test.ts +++ b/tests/unit/nvidia-passthrough-models-6773.test.ts @@ -2,8 +2,8 @@ * Regression test for #6773 — NVIDIA NIM models listed available:true but 404 at router. * * Root cause: the `nvidia` provider registry entry multiplexes many distinct - * third-party vendor models (z-ai/, minimaxai/, deepseek-ai/, qwen/, - * mistralai/, stepfun-ai/, moonshotai/, openai/, nvidia/) behind ONE base URL + * third-party vendor models (moonshotai/, deepseek-ai/, nvidia/, meta/, + * poolside/, google/, openai/) behind ONE base URL * and ONE API key connection — architecturally identical to `modelscope`, * `synthetic`, and `kilo-gateway`, which all set `passthroughModels: true` so * that a single model's 404/429 stays scoped to that model instead of cooling @@ -24,8 +24,8 @@ test("#6773: nvidia registry entry sets passthroughModels", () => { entry?.passthroughModels, true, "nvidia multiplexes many third-party vendor models behind one connection " + - "(z-ai/, minimaxai/, deepseek-ai/, qwen/, mistralai/, stepfun-ai/, " + - "moonshotai/, openai/, nvidia/) — it should set passthroughModels: true " + + "(moonshotai/, deepseek-ai/, nvidia/, meta/, poolside/, google/, " + + "openai/) — it should set passthroughModels: true " + "like modelscope/synthetic/kilo-gateway, so a single stale model 404 " + "does not cool down the whole connection for all other models" ); @@ -33,7 +33,7 @@ test("#6773: nvidia registry entry sets passthroughModels", () => { test("#6773: hasPerModelQuota('nvidia') is true, so a 404 on one nvidia model is model-scoped", () => { assert.equal( - accountFallback.hasPerModelQuota("nvidia", "z-ai/glm-5.2"), + accountFallback.hasPerModelQuota("nvidia", "nvidia/nemotron-3.5-lightning-30b-a3b"), true, "expected nvidia to use per-model lockout (like gemini/github/codex/compatible " + "providers) so a 404 on one model doesn't cool down the other nvidia models" @@ -50,7 +50,7 @@ test("#6773: checkFallbackError + lockModelIfPerModelQuota scope a single-model 404, "Not Found", 0, - "z-ai/glm-5.2", + "nvidia/nemotron-3.5-lightning-30b-a3b", "nvidia", null, null, @@ -66,7 +66,7 @@ test("#6773: checkFallbackError + lockModelIfPerModelQuota scope a single-model const locked = accountFallback.lockModelIfPerModelQuota( "nvidia", "conn-6773", - "z-ai/glm-5.2", + "nvidia/nemotron-3.5-lightning-30b-a3b", "unknown", result.cooldownMs ?? 30_000 ); diff --git a/tests/unit/nvidia-validation-model-3116.test.ts b/tests/unit/nvidia-validation-model-3116.test.ts index 1d52f68059..da7e541fd2 100644 --- a/tests/unit/nvidia-validation-model-3116.test.ts +++ b/tests/unit/nvidia-validation-model-3116.test.ts @@ -2,7 +2,7 @@ * #3116 — NVIDIA key validation probed the first catalog model (`z-ai/glm-5.1`), which * requires the "Public API Endpoints" account permission and can hang/be DEGRADED, * making a *valid* key fail with a misleading "Upstream Error". The probe now defaults to - * the universally-available `meta/llama-3.1-8b-instruct`, with a per-connection override. + * a lightweight model from the current hosted catalog, with a per-connection override. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -12,10 +12,10 @@ import { resolveNvidiaValidationModel, } from "../../src/lib/providers/nvidiaValidationModel.ts"; -test("defaults to a stable, permission-free NVIDIA model (not the gated glm-5.1)", () => { - assert.equal(NVIDIA_DEFAULT_VALIDATION_MODEL, "meta/llama-3.1-8b-instruct"); - assert.equal(resolveNvidiaValidationModel(), "meta/llama-3.1-8b-instruct"); - assert.equal(resolveNvidiaValidationModel({}), "meta/llama-3.1-8b-instruct"); +test("defaults to a lightweight model in the current NVIDIA catalog", () => { + assert.equal(NVIDIA_DEFAULT_VALIDATION_MODEL, "nvidia/nemotron-3.5-lightning-30b-a3b"); + assert.equal(resolveNvidiaValidationModel(), "nvidia/nemotron-3.5-lightning-30b-a3b"); + assert.equal(resolveNvidiaValidationModel({}), "nvidia/nemotron-3.5-lightning-30b-a3b"); assert.notEqual(resolveNvidiaValidationModel(undefined), "z-ai/glm-5.1"); }); @@ -25,5 +25,8 @@ test("honors a per-connection validationModelId override", () => { "nvidia/llama-3.3-nemotron-super-49b" ); // blank/whitespace override falls back to the default - assert.equal(resolveNvidiaValidationModel({ validationModelId: " " }), NVIDIA_DEFAULT_VALIDATION_MODEL); + assert.equal( + resolveNvidiaValidationModel({ validationModelId: " " }), + NVIDIA_DEFAULT_VALIDATION_MODEL + ); }); diff --git a/tests/unit/obscura-integration.test.ts b/tests/unit/obscura-integration.test.ts new file mode 100644 index 0000000000..67a3006d79 --- /dev/null +++ b/tests/unit/obscura-integration.test.ts @@ -0,0 +1,155 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { createServer } from "node:net"; + +import { + connectObscuraBrowser, + ensureObscuraServer, + killSharedObscuraServer, + isObscuraUsable, +} from "../../open-sse/services/obscura.ts"; + +// #12274 — Obscura-first browser engine. The shared server is process-lifetime; +// each test resets it so suites run independently. When `obscura` is not +// installed the live tests skip; the null-return path is still covered. + +const BIN_RESULT = spawnSync("which", ["obscura"], { encoding: "utf8" }); +const HAS_OBSCURA = BIN_RESULT.status === 0 && BIN_RESULT.stdout.trim().length > 0; + +function freePort(): Promise { + return new Promise((resolve, reject) => { + const srv = createServer(); + srv.once("error", reject); + srv.listen(0, "127.0.0.1", () => { + const address = srv.address(); + srv.close(() => { + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("no free port")); + }); + }); + }); +} + +async function waitForCdp(endpoint: string, timeoutMs = 30_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1500); + // Probe /json/version (Obscura's bare "/" never completes a response). + const probe = endpoint.replace(/^ws/, "http").replace(/\/$/, "") + "/json/version"; + const res = await fetch(probe, { signal: controller.signal }); + clearTimeout(timer); + if (res.ok) return true; + } catch { + /* not up yet */ + } + await new Promise((r) => setTimeout(r, 250)); + } + return false; +} + +describe("obscura engine", () => { + it("respects OMNIROUTE_BROWSER_POOL=off", () => { + const original = process.env.OMNIROUTE_BROWSER_POOL; + process.env.OMNIROUTE_BROWSER_POOL = "off"; + try { + assert.equal(isObscuraUsable(), false); + } finally { + if (original === undefined) delete process.env.OMNIROUTE_BROWSER_POOL; + else process.env.OMNIROUTE_BROWSER_POOL = original; + } + }); + + it("is enabled by default (no env var)", () => { + const original = process.env.OMNIROUTE_BROWSER_POOL; + delete process.env.OMNIROUTE_BROWSER_POOL; + try { + assert.equal(isObscuraUsable(), true); + } finally { + if (original !== undefined) process.env.OMNIROUTE_BROWSER_POOL = original; + } + }); + + it("returns null when the binary is absent or cannot start", async () => { + killSharedObscuraServer(); + const originalBin = process.env.OBSCURA_BIN; + const originalEndpoint = process.env.OBSCURA_CDP_ENDPOINT; + process.env.OBSCURA_BIN = "/nonexistent/obscura"; + delete process.env.OBSCURA_CDP_ENDPOINT; + try { + const server = await ensureObscuraServer(); + assert.equal(server, null); + } finally { + if (originalBin === undefined) delete process.env.OBSCURA_BIN; + else process.env.OBSCURA_BIN = originalBin; + if (originalEndpoint === undefined) delete process.env.OBSCURA_CDP_ENDPOINT; + else process.env.OBSCURA_CDP_ENDPOINT = originalEndpoint; + killSharedObscuraServer(); + } + }); + + it("round-trips a page through Obscura when installed", async (t) => { + killSharedObscuraServer(); + if (!HAS_OBSCURA) { + t.skip("obscura binary not installed"); + return; + } + try { + const connection = await connectObscuraBrowser(); + assert.ok(connection, "expected a live Obscura connection"); + const { browser } = connection; + const context = await browser.newContext({ userAgent: "obscura-integration-test" }); + const page = await context.newPage(); + await page.goto("https://example.com", { waitUntil: "domcontentloaded", timeout: 30000 }); + const title = await page.title(); + assert.equal(title, "Example Domain"); + await context.close(); + await browser.close(); + } finally { + killSharedObscuraServer(); + } + }); + + it("connects to an external endpoint without owning its process", async (t) => { + void t; + killSharedObscuraServer(); + if (!HAS_OBSCURA) { + t.skip("obscura binary not installed"); + return; + } + // Standalone server we own outside the module, referenced as "external". + const port = await freePort(); + const child = spawn( + process.env.OBSCURA_BIN ?? "obscura", + ["serve", "--port", String(port), "--host", "127.0.0.1"], + { stdio: ["ignore", "ignore", "pipe"] } + ); + const endpoint = `http://127.0.0.1:${port}`; + const ready = await waitForCdp(endpoint); + if (!ready) { + child.kill("SIGKILL"); + t.skip("external obscura server did not come up"); + return; + } + const originalBin = process.env.OBSCURA_BIN; + const originalEndpoint = process.env.OBSCURA_CDP_ENDPOINT; + process.env.OBSCURA_CDP_ENDPOINT = endpoint; + process.env.OBSCURA_BIN = "/nonexistent/obscura"; // force the external path + try { + const connection = await connectObscuraBrowser(); + assert.ok(connection); + assert.equal(connection.child, null, "external endpoint must not own a child process"); + assert.ok(connection.browser.version().length > 0); + await connection.browser.close(); + } finally { + if (originalBin === undefined) delete process.env.OBSCURA_BIN; + else process.env.OBSCURA_BIN = originalBin; + if (originalEndpoint === undefined) delete process.env.OBSCURA_CDP_ENDPOINT; + else process.env.OBSCURA_CDP_ENDPOINT = originalEndpoint; + child.kill("SIGKILL"); + killSharedObscuraServer(); + } + }); +}); diff --git a/tests/unit/openapi-security-tiers.test.ts b/tests/unit/openapi-security-tiers.test.ts index 8203ab6f2d..d1380a5c08 100644 --- a/tests/unit/openapi-security-tiers.test.ts +++ b/tests/unit/openapi-security-tiers.test.ts @@ -7,8 +7,12 @@ import * as yaml from "js-yaml"; const ROOT = process.cwd(); const OPENAPI_PATH = path.join(ROOT, "docs", "openapi.yaml"); -const { LOCAL_ONLY_API_PREFIXES, LOCAL_ONLY_API_PATTERNS, ALWAYS_PROTECTED_API_PATHS } = - await import("../../src/server/authz/routeGuard.ts"); +const { + LOCAL_ONLY_API_PREFIXES, + LOCAL_ONLY_API_PATTERNS, + ALWAYS_PROTECTED_API_PATHS, + ALWAYS_PROTECTED_API_PATTERNS, +} = await import("../../src/server/authz/routeGuard.ts"); const raw: any = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); const paths: Record = raw.paths || {}; @@ -131,12 +135,22 @@ test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeG for (const [method, spec] of Object.entries(methods as Record)) { if (!["get", "post", "put", "patch", "delete"].includes(method)) continue; if (spec?.["x-always-protected"] !== true) continue; - const matchesPath = (ALWAYS_PROTECTED_API_PATHS as ReadonlyArray).some( - (p: string) => pathStr === p || pathStr.startsWith(`${p}/`) - ); + // Routes with a dynamic segment cannot be expressed in the plain + // exact/prefix list, so routeGuard also carries ALWAYS_PROTECTED_API_PATTERNS + // (GHSA-5926-2w35-7h4q). Substitute a concrete value for the OpenAPI + // `{param}` placeholders before testing those. + const concretePath = pathStr.replace(/\{[^}]+\}/g, "sample-id"); + const matchesPath = + (ALWAYS_PROTECTED_API_PATHS as ReadonlyArray).some( + (p: string) => pathStr === p || pathStr.startsWith(`${p}/`) + ) || + (ALWAYS_PROTECTED_API_PATTERNS as ReadonlyArray).some((re) => + re.test(concretePath) + ); assert.ok( matchesPath, - `YAML path "${pathStr}" ${method.toUpperCase()} has x-always-protected but is NOT in ALWAYS_PROTECTED_API_PATHS. ` + + `YAML path "${pathStr}" ${method.toUpperCase()} has x-always-protected but is NOT in ALWAYS_PROTECTED_API_PATHS ` + + `nor matched by ALWAYS_PROTECTED_API_PATTERNS. ` + `Entries: ${(ALWAYS_PROTECTED_API_PATHS as ReadonlyArray).join(", ")}` ); } diff --git a/tests/unit/opencode-go-effort-aliases-8353.test.ts b/tests/unit/opencode-go-effort-aliases-8353.test.ts index 762de5fcd2..c743623f93 100644 --- a/tests/unit/opencode-go-effort-aliases-8353.test.ts +++ b/tests/unit/opencode-go-effort-aliases-8353.test.ts @@ -283,12 +283,3 @@ test("#10788 registry base rows declare the same tiers EFFORT_TIERS parses", () } } }); - -test("#10788 nvidia z-ai/glm-5.2 declares reasoning with an empty tier list (binary switch)", () => { - const entry = REGISTRY["nvidia"]; - assert.ok(entry?.models, "nvidia must expose models"); - const row = entry.models.find((m) => m.id === "z-ai/glm-5.2"); - assert.ok(row, "nvidia z-ai/glm-5.2 must exist"); - assert.equal(row.supportsReasoning, true); - assert.deepEqual(row.supportedThinkingEfforts, []); -}); diff --git a/tests/unit/pricing-constants-split.test.ts b/tests/unit/pricing-constants-split.test.ts index 28acb759c4..90970e1730 100644 --- a/tests/unit/pricing-constants-split.test.ts +++ b/tests/unit/pricing-constants-split.test.ts @@ -1,5 +1,5 @@ // Characterization of the pricing.ts split (god-file decomposition): the host became a barrel that -// re-exports DEFAULT_PRICING (now merged from 4 semantic family files that import shared tier consts) +// re-exports DEFAULT_PRICING (merged from semantic family files that import shared tier consts) // and keeps the helper functions. Pure-data move → behavior identical. Locks: public surface, the // spread-merge integrity, and that lookups/cost math resolve unchanged. import { test } from "node:test"; @@ -14,13 +14,14 @@ test("barrel still exports DEFAULT_PRICING + supported helpers", () => { assert.equal(Object.hasOwn(P, "calculateCostFromTokens"), false); }); -test("DEFAULT_PRICING merges the 4 family files; families partition all entries", async () => { +test("DEFAULT_PRICING merges every family file; families partition all entries", async () => { const merged = Object.keys((P as Record).DEFAULT_PRICING).length; const families: [string, string][] = [ ["oauth-subscriptions", "DEFAULT_PRICING_OAUTH"], ["frontier-labs", "DEFAULT_PRICING_FRONTIER"], ["inference-hosts", "DEFAULT_PRICING_INFERENCE"], ["regional", "DEFAULT_PRICING_REGIONAL"], + ["devin", "DEFAULT_PRICING_DEVIN"], ]; let famTotal = 0; const seen = new Set(); diff --git a/tests/unit/provider-cost-data.test.ts b/tests/unit/provider-cost-data.test.ts new file mode 100644 index 0000000000..157769dfbf --- /dev/null +++ b/tests/unit/provider-cost-data.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getModelPricing, KNOWN_MODEL_PRICING } from "../../open-sse/services/providerCostData.ts"; + +test("provider-specific pricing wins over a generic model fallback", () => { + const genericKey = "provider-price-test-model"; + const providerKey = `devin-cli/${genericKey}`; + const previousGeneric = KNOWN_MODEL_PRICING[genericKey]; + const previousProvider = KNOWN_MODEL_PRICING[providerKey]; + + KNOWN_MODEL_PRICING[genericKey] = { + inputCostPer1M: 9, + outputCostPer1M: 90, + isFree: false, + }; + KNOWN_MODEL_PRICING[providerKey] = { + inputCostPer1M: 1, + outputCostPer1M: 10, + isFree: false, + }; + + try { + assert.deepEqual(getModelPricing("devin-cli", genericKey), { + inputCostPer1M: 1, + outputCostPer1M: 10, + isFree: false, + }); + } finally { + if (previousGeneric) KNOWN_MODEL_PRICING[genericKey] = previousGeneric; + else delete KNOWN_MODEL_PRICING[genericKey]; + if (previousProvider) KNOWN_MODEL_PRICING[providerKey] = previousProvider; + else delete KNOWN_MODEL_PRICING[providerKey]; + } +}); + +test("tier pricing reads the exact Devin provider/model rate", () => { + assert.deepEqual(getModelPricing("devin-cli", "gpt-5-6-luna-max"), { + inputCostPer1M: 0.2, + outputCostPer1M: 1.2, + isFree: false, + }); + assert.deepEqual(getModelPricing("devin-cli", "gpt-5-6-luna-max-priority"), { + inputCostPer1M: 0.4, + outputCostPer1M: 2.4, + isFree: false, + }); +}); diff --git a/tests/unit/provider-import-feedback-12071.test.ts b/tests/unit/provider-import-feedback-12071.test.ts new file mode 100644 index 0000000000..8db247a84e --- /dev/null +++ b/tests/unit/provider-import-feedback-12071.test.ts @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const feedback = await import( + "../../src/app/(dashboard)/dashboard/providers/components/providerImportFeedback.ts" +); +const { parseProviderImportFile } = await import( + "../../src/app/(dashboard)/dashboard/providers/components/parseProviderImportFile.ts" +); + +test("#12071 normalizeImportResponse keeps the per-row errors array", () => { + const result = feedback.normalizeImportResponse({ + success: 1, + failed: 2, + total: 3, + errors: [ + { index: 1, name: "srv-107", provider: "openai-compatible-chat-001", message: "Unknown or unsupported provider" }, + { index: 2, name: "srv-135", provider: "openai", message: "Provider node not found" }, + ], + }); + assert.equal(result.success, 1); + assert.equal(result.failed, 2); + assert.equal(result.total, 3); + assert.equal(result.errors.length, 2); + assert.equal(result.errors[0].message, "Unknown or unsupported provider"); + assert.equal(result.errors[1].name, "srv-135"); +}); + +test("#12071 normalizeImportResponse treats a missing errors field as [] (today's silent drop)", () => { + const result = feedback.normalizeImportResponse({ success: 0, failed: 3, total: 3 }); + assert.deepEqual(result.errors, []); + assert.equal(result.failed, 3); +}); + +test("#12071 normalizeImportResponse ignores a non-array errors field", () => { + const result = feedback.normalizeImportResponse({ success: 0, failed: 1, total: 1, errors: "boom" }); + assert.deepEqual(result.errors, []); +}); + +test("#12071 visibleImportErrors caps at 10 and reports the remainder", () => { + const errors = Array.from({ length: 12 }, (_, i) => ({ message: `row ${i}` })); + const { shown, extra } = feedback.visibleImportErrors(errors); + assert.equal(shown.length, 10); + assert.equal(extra, 2); + assert.equal(shown[0].message, "row 0"); +}); + +test("#12071 formatImportErrorLine prefers name, then provider, then 1-based row", () => { + assert.equal( + feedback.formatImportErrorLine({ name: "Grade-S-Node", message: "Unknown or unsupported provider" }), + "Grade-S-Node: Unknown or unsupported provider" + ); + assert.equal( + feedback.formatImportErrorLine({ provider: "openai", message: "Provider node not found" }), + "openai: Provider node not found" + ); + assert.equal(feedback.formatImportErrorLine({ index: 0, message: "failed" }), "row 1: failed"); +}); + +test("#12071 CSV template is positional and parses to one openai row", () => { + const parsed = parseProviderImportFile(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, "csv"); + assert.equal(parsed.errors.length, 0); + assert.equal(parsed.entries.length, 1); + assert.equal(parsed.entries[0].provider, "openai"); + assert.equal(parsed.entries[0].name, "Prod OpenAI"); + assert.equal(parsed.entries[0].apiKey, "sk-your-openai-key"); + assert.equal(parsed.entries[0].priority, 1); +}); + +test("#12071 CSV template comments document that provider must already exist", () => { + assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /existing managed provider/i); + assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /does not create new endpoint nodes/i); + assert.match(feedback.PROVIDER_IMPORT_CSV_TEMPLATE, /positional/i); +}); + +test("#12071 asRowError trims leading/trailing whitespace on message", () => { + const result = feedback.normalizeImportResponse({ + success: 0, + failed: 1, + total: 1, + errors: [{ name: "srv-107", message: " Unknown or unsupported provider " }], + }); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0].message, "Unknown or unsupported provider"); +}); + +test("#12071 applyImportHttpOutcome on !ok zeros success even if the body claimed some", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: false, status: 500 }, + { + success: 5, + failed: 0, + total: 5, + errors: [{ name: "a", message: "Unknown or unsupported provider" }], + } + ); + assert.equal(outcome.shouldRefresh, false); + assert.equal(outcome.result.success, 0); + assert.equal(outcome.result.errors.length, 1); +}); + +test("#12071 applyImportHttpOutcome surfaces non-ok HTTP without calling onImported", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: false, status: 400 }, + { error: "Invalid JSON body" } + ); + assert.equal(outcome.shouldRefresh, false); + assert.equal(outcome.result.success, 0); + assert.equal(outcome.result.failed, 1); + assert.equal(outcome.result.errors.length, 1); + assert.match(outcome.result.errors[0].message, /HTTP 400/); +}); + +test("#12071 applyImportHttpOutcome on ok with success>0 requests refresh", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: true, status: 200 }, + { success: 2, failed: 1, total: 3, errors: [{ name: "bad", message: "Unknown or unsupported provider" }] } + ); + assert.equal(outcome.shouldRefresh, true); + assert.equal(outcome.result.success, 2); + assert.equal(outcome.result.failed, 1); + assert.equal(outcome.result.errors[0].name, "bad"); +}); + +test("#12071 applyImportHttpOutcome on ok with success=0 still keeps errors and skips refresh", () => { + const outcome = feedback.applyImportHttpOutcome( + { ok: true, status: 200 }, + { success: 0, failed: 3, total: 3, errors: [{ name: "a", message: "Unknown or unsupported provider" }] } + ); + assert.equal(outcome.shouldRefresh, false); + assert.equal(outcome.result.success, 0); + assert.equal(outcome.result.errors.length, 1); +}); + +test("#12071 readImportResponse treats JSON parse failure as !ok with a body error", async () => { + const res = new Response("not-json", { status: 200, headers: { "Content-Type": "text/plain" } }); + const parsed = await feedback.readImportResponse(res); + assert.equal(parsed.ok, false); + assert.equal(parsed.status, 200); + const outcome = feedback.applyImportHttpOutcome(parsed, parsed.data); + assert.equal(outcome.shouldRefresh, false); + assert.match(outcome.result.errors[0].message, /Invalid JSON body/); +}); diff --git a/tests/unit/provider-models-config.test.ts b/tests/unit/provider-models-config.test.ts index 793605cc4f..51a5f62d18 100644 --- a/tests/unit/provider-models-config.test.ts +++ b/tests/unit/provider-models-config.test.ts @@ -141,16 +141,22 @@ test("GitHub Copilot registry reflects the current supported model lineup", () = assert.equal(ids.includes("gemini-3-flash-preview"), false); }); -test("Claude flagship catalogs keep Fable 5 first", () => { - for (const provider of ["anthropic", "cc", "cw", "gh", "ghe-copilot"]) { +test("verified Anthropic launch catalogs keep Fable 5.1 first", () => { + for (const provider of ["anthropic", "cc", "cw"]) { assert.equal( getProviderModels(provider)[0]?.id, - "claude-fable-5", + "claude-fable-5-1", `${provider} must list the strongest Claude model first` ); } }); +test("Copilot catalogs retain Fable 5 until their Fable 5.1 IDs are verified", () => { + for (const provider of ["gh", "ghe-copilot"]) { + assert.equal(getProviderModels(provider)[0]?.id, "claude-fable-5"); + } +}); + test("Kiro registry exposes the current CLI model lineup with context windows", () => { const kiroModels = getProviderModels("kr"); const byId = new Map(kiroModels.map((model) => [model.id, model])); diff --git a/tests/unit/provider-node-daily-reset-schema.test.ts b/tests/unit/provider-node-daily-reset-schema.test.ts new file mode 100644 index 0000000000..f20a918b68 --- /dev/null +++ b/tests/unit/provider-node-daily-reset-schema.test.ts @@ -0,0 +1,54 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createProviderNodeSchema, updateProviderNodeSchema } = + await import("../../src/shared/validation/schemas/provider.ts"); + +const BASE = { + name: "Moonshot native", + prefix: "mnative", + baseUrl: "https://api.moonshot.cn/v1", +}; + +test("updateProviderNodeSchema accepts IANA timezone + hour 0", () => { + const result = updateProviderNodeSchema.safeParse({ + ...BASE, + dailyQuotaResetTimezone: "Asia/Shanghai", + dailyQuotaResetHour: 0, + }); + assert.equal(result.success, true); +}); + +test("updateProviderNodeSchema rejects unknown timezone", () => { + const result = updateProviderNodeSchema.safeParse({ + ...BASE, + dailyQuotaResetTimezone: "Shanghai", + dailyQuotaResetHour: 0, + }); + assert.equal(result.success, false); +}); + +test("updateProviderNodeSchema rejects hour 24", () => { + const result = updateProviderNodeSchema.safeParse({ + ...BASE, + dailyQuotaResetTimezone: "Asia/Shanghai", + dailyQuotaResetHour: 24, + }); + assert.equal(result.success, false); +}); + +test("updateProviderNodeSchema accepts omitted reset clock", () => { + const result = updateProviderNodeSchema.safeParse(BASE); + assert.equal(result.success, true); +}); + +test("createProviderNodeSchema accepts IANA timezone + hour", () => { + const result = createProviderNodeSchema.safeParse({ + ...BASE, + apiType: "chat", + type: "openai-compatible", + dailyQuotaResetTimezone: "UTC", + dailyQuotaResetHour: 0, + }); + assert.equal(result.success, true); +}); diff --git a/tests/unit/provider-node-reserved-prefix.test.ts b/tests/unit/provider-node-reserved-prefix.test.ts index 26c2250753..a20918edab 100644 --- a/tests/unit/provider-node-reserved-prefix.test.ts +++ b/tests/unit/provider-node-reserved-prefix.test.ts @@ -179,7 +179,8 @@ test("shared set size includes live REGISTRY and retired Designer + Felo + Qwen // alias "gembiz" to the REGISTRY walk (406 → 408). // 2026-09-02: a keyless provider was removed at its operator's request, taking its id and // alias out of the REGISTRY walk (408 → 406). - assert.equal(RESERVED_PREFIX_COUNT, 406); + // #11786: SeekAi adds id "seekai" + alias "ska" (406 → 408). + assert.equal(RESERVED_PREFIX_COUNT, 408); }); test("isReservedProviderPrefix rejects non-string input", () => { diff --git a/tests/unit/provider-quota-visibility.test.ts b/tests/unit/provider-quota-visibility.test.ts index 665687feed..a8551e3ccb 100644 --- a/tests/unit/provider-quota-visibility.test.ts +++ b/tests/unit/provider-quota-visibility.test.ts @@ -16,3 +16,15 @@ test("quota visibility controls are limited to providers with quota support", () assert.equal(supportsProviderQuota("codex"), true); assert.equal(supportsProviderQuota("openai"), false); }); + +test("supportsProviderQuota is true for moonshot-native shaped connection", () => { + assert.equal( + supportsProviderQuota("openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf", { + providerSpecificData: { baseUrl: "https://api.moonshot.cn/v1" }, + }), + true, + ); + assert.equal(supportsProviderQuota("moonshot"), true); + assert.equal(supportsProviderQuota("kimi"), true); + assert.equal(supportsProviderQuota("openai"), false); +}); diff --git a/tests/unit/provider-search-connection-identity-12108.test.ts b/tests/unit/provider-search-connection-identity-12108.test.ts new file mode 100644 index 0000000000..e6d8d5a556 --- /dev/null +++ b/tests/unit/provider-search-connection-identity-12108.test.ts @@ -0,0 +1,92 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { filterConfiguredProviderEntries } = await import( + "../../src/app/(dashboard)/dashboard/providers/providerPageUtils.ts" +); + +const ENTRIES = [ + { + providerId: "openai", + provider: { id: "openai", name: "OpenAI" }, + stats: { total: 1 }, + displayAuthType: "apikey" as const, + toggleAuthType: "apikey" as const, + }, + { + providerId: "claude", + provider: { id: "claude", name: "Claude" }, + stats: { total: 0 }, + displayAuthType: "oauth" as const, + toggleAuthType: "oauth" as const, + }, +]; + +const CONNECTIONS = [ + { + provider: "openai", + name: "Grade-S-Node", + providerSpecificData: { baseUrl: "http://145.10.20.30:8080" }, + }, +]; + +function ids(query: string, connections = CONNECTIONS) { + return filterConfiguredProviderEntries( + ENTRIES, + false, + query, + false, + "", + null, + undefined, + connections + ).map((e) => e.providerId); +} + +test("#12108 top-level search matches connection name (imported Grade-S-Node)", () => { + assert.deepEqual(ids("Grade-S-Node"), ["openai"]); +}); + +test("#12108 top-level search matches connection baseUrl host", () => { + assert.deepEqual(ids("145.10.20.30"), ["openai"]); +}); + +test("#12108 top-level search still matches static provider name", () => { + assert.deepEqual(ids("claude"), ["claude"]); +}); + +test("#12108 top-level search without connections does not invent a name match", () => { + assert.deepEqual(ids("Grade-S-Node", []), []); + const withoutArg = filterConfiguredProviderEntries(ENTRIES, false, "Grade-S-Node").map( + (e) => e.providerId + ); + assert.deepEqual(withoutArg, []); +}); + +test("#12108 empty search still returns every entry", () => { + assert.deepEqual(new Set(ids("")), new Set(["openai", "claude"])); +}); + +test("#12108 a connection on openai does not surface claude", () => { + assert.equal(ids("Grade-S-Node").includes("claude"), false); +}); + +test("#12108 dashboard card search does not match connection email/tag/id", () => { + const withAccountFields = [ + { + provider: "openai", + name: "Grade-S-Node", + id: "conn-grade", + email: "ops@grade.example", + providerSpecificData: { tag: "prod-east", baseUrl: "http://145.10.20.30:8080" }, + }, + ]; + assert.deepEqual(ids("ops@grade.example", withAccountFields), []); + assert.deepEqual(ids("prod-east", withAccountFields), []); + assert.deepEqual(ids("conn-grade", withAccountFields), []); + assert.deepEqual(ids("Grade-S-Node", withAccountFields), ["openai"]); +}); + +test("#12108 connection haystack uses matchesAnyToken (token OR, same as provider.name)", () => { + assert.deepEqual(ids("Grade Node"), ["openai"]); +}); diff --git a/tests/unit/providers-constants-split.test.ts b/tests/unit/providers-constants-split.test.ts index d6196816ca..6d4ae62c89 100644 --- a/tests/unit/providers-constants-split.test.ts +++ b/tests/unit/providers-constants-split.test.ts @@ -32,7 +32,8 @@ // volcengine-coding-plan (regional family) — both land at 233. // release/v3.8.51 adds Opper (gateways, #11629) and 1min.ai (gateways, #11631) — lands at 235; // Perplexity Agent API (#12103) makes it 236; -// UC Direct (#11513, uncensored.com metered Developer API) adds one frontier-labs entry — 237. +// UC Direct (#11513, uncensored.com metered Developer API) adds one frontier-labs entry — 237; +// SeekAi (#11786, QuantumNous New-API gateway) adds one gateways entry — 238. import { test } from "node:test"; import assert from "node:assert/strict"; @@ -61,12 +62,12 @@ test("barrel still exports every catalog + key helpers", () => { } }); -test("APIKEY_PROVIDERS merges the 6 family files into 237 entries (no loss / no dup)", async () => { +test("APIKEY_PROVIDERS merges the 6 family files into 238 entries (no loss / no dup)", async () => { const keys = Object.keys((P as Record).APIKEY_PROVIDERS); - assert.equal(keys.length, 237); - assert.equal(new Set(keys).size, 237, "duplicate keys after spread-merge"); + assert.equal(keys.length, 238); + assert.equal(new Set(keys).size, 238, "duplicate keys after spread-merge"); // the merged object's entry-count equals the sum of the 6 semantic family files; families are a - // strict partition (every provider in exactly one), so the sum must be exactly 237. + // strict partition (every provider in exactly one), so the sum must be exactly 238. const families: [string, string][] = [ ["gateways", "APIKEY_PROVIDERS_GATEWAYS"], ["frontier-labs", "APIKEY_PROVIDERS_FRONTIER"], @@ -86,7 +87,7 @@ test("APIKEY_PROVIDERS merges the 6 family files into 237 entries (no loss / no seen.add(k); } } - assert.equal(famTotal, 237, "families must partition all 237 providers"); + assert.equal(famTotal, 238, "families must partition all 238 providers"); }); test("AI_PROVIDERS Proxy aggregates all sections; lookups resolve", () => { diff --git a/tests/unit/qoder-usage-quota.test.ts b/tests/unit/qoder-usage-quota.test.ts index 14666abc11..8e69a8991e 100644 --- a/tests/unit/qoder-usage-quota.test.ts +++ b/tests/unit/qoder-usage-quota.test.ts @@ -148,6 +148,15 @@ test("a qoder PAT (apikey) connection is picked up by the provider-limits sync", isSupportedUsageConnection({ id: "c2", provider: "some-random-provider", authType: "apikey" }), false ); + assert.equal( + isSupportedUsageConnection({ + id: "c3", + provider: "openai-compatible-chat-e2971611-bc02-4c37-8fc5-39b8e3906fdf", + authType: "apikey", + providerSpecificData: { baseUrl: "https://api.moonshot.cn/v1" }, + }), + true, + ); }); // Guards the shared exchange contract the usage path relies on. diff --git a/tests/unit/responses-continuation-store.test.ts b/tests/unit/responses-continuation-store.test.ts index 6c75d53c24..4f414c4e16 100644 --- a/tests/unit/responses-continuation-store.test.ts +++ b/tests/unit/responses-continuation-store.test.ts @@ -261,6 +261,64 @@ test("resolvePreviousResponseState fails closed when the stored input array was assert.equal(store.resolvePreviousResponseState("resp_gen-truncated-history", "key-1"), null); }); +test("resolvePreviousResponseState fails closed when the streaming collector truncated the response", () => { + // Live incident (2026-09-02): a huge/reasoning-heavy response blew past + // createStructuredSSECollector's own event-count cap mid-stream. The + // stored clientResponse then carries `_truncated: true` and + // `summary.status: "in_progress"` (never reached "completed") with a + // genuinely empty `summary.output` -- not a bounded array with an + // `_omniroute_truncated_array` sentinel (that only covers an array capped + // mid-array, not a collector that stopped before populating output at + // all). The empty array previously passed every check here and got + // merged into the next turn's request as this response's entire + // contribution -- reconstructing to zero real messages, which the + // upstream provider then rejected outright ("Input required: specify + // prompt or messages"), breaking the conversation. Measured live: ~22% + // of a sample of recent successful Ping responses carried this flag. + insertCallLog({ + id: "log-8", + responseId: "resp_gen-collector-truncated", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-8.json", + }); + writeArtifact("2026-01-01/log-8.json", { + clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + clientResponse: { + _streamed: true, + _truncated: true, + _droppedEvents: 24, + summary: { id: "resp_gen-collector-truncated", status: "in_progress", output: [] }, + }, + }); + + assert.equal(store.resolvePreviousResponseState("resp_gen-collector-truncated", "key-1"), null); +}); + +test("resolvePreviousResponseState fails closed on an empty output array even without the _truncated flag", () => { + // Belt-and-suspenders for the same failure class when the collector + // truncated without ever setting `_truncated` (or for a non-streaming + // response that somehow logged zero output items): a response the + // client actually received as real/successful always has at least one + // output item, so an empty array here is never a legitimate prior turn + // to reconstruct from. + insertCallLog({ + id: "log-9", + responseId: "resp_gen-empty-output", + apiKeyId: "key-1", + detailState: "ready", + artifactRelPath: "2026-01-01/log-9.json", + }); + writeArtifact("2026-01-01/log-9.json", { + clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } }, + clientResponse: { id: "resp_gen-empty-output", output: [] }, + }); + + assert.equal(store.resolvePreviousResponseState("resp_gen-empty-output", "key-1"), null); +}); + test("resolvePreviousResponseState returns null when detail logging was never captured for this row", () => { insertCallLog({ id: "log-5", diff --git a/tests/unit/seekai-provider.test.ts b/tests/unit/seekai-provider.test.ts new file mode 100644 index 0000000000..13f3ca512a --- /dev/null +++ b/tests/unit/seekai-provider.test.ts @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { APIKEY_PROVIDERS, AGGREGATOR_PROVIDER_IDS } = await import( + "../../src/shared/constants/providers.ts" +); +const { PROVIDER_ENDPOINTS } = await import("../../src/shared/constants/config.ts"); +const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts"); +const { isValidModel } = await import("../../src/shared/constants/models.ts"); +const { DefaultExecutor, getExecutor } = await import("../../open-sse/executors/index.ts"); + +const SEEKAI_CHAT_URL = "https://seekai.cc/v1/chat/completions"; +const SEEKAI_MODELS_URL = "https://seekai.cc/v1/models"; + +test("#11786 seekai is registered as an API-key gateway provider", () => { + const entry = APIKEY_PROVIDERS.seekai; + assert.ok(entry, "APIKEY_PROVIDERS.seekai must be defined"); + assert.equal(entry.id, "seekai"); + assert.equal(entry.alias, "ska"); + assert.equal(entry.name, "SeekAi"); + assert.equal(entry.website, "https://seekai.cc"); + assert.equal(entry.passthroughModels, true); + assert.equal(entry.hasFree, true); + assert.equal(typeof entry.authHint, "string"); + assert.ok((entry.authHint as string).length > 0); + assert.equal(typeof entry.apiHint, "string"); + assert.ok((entry.apiHint as string).length > 0); +}); + +test("#11786 seekai website and hints carry no referral/aff query", () => { + const entry = APIKEY_PROVIDERS.seekai; + const haystack = [entry.website, entry.apiHint, entry.authHint, entry.freeNote] + .filter((value): value is string => typeof value === "string") + .join("\n"); + assert.equal(/[?&]aff=/.test(haystack), false); + assert.equal(haystack.includes("qR5U"), false); +}); + +test("#11786 seekai registry entry uses OpenAI format with bearer API-key auth", () => { + const entry = providerRegistry.seekai; + assert.ok(entry, "providerRegistry.seekai must be defined"); + assert.equal(entry.id, "seekai"); + assert.equal(entry.alias, "ska"); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + assert.equal(entry.baseUrl, SEEKAI_CHAT_URL); + assert.equal(entry.modelsUrl, SEEKAI_MODELS_URL); + assert.equal(entry.passthroughModels, true); +}); + +test("#11786 seekai discovers models live via passthrough (no static seed list)", () => { + assert.deepEqual(providerRegistry.seekai.models, []); + assert.equal(providerRegistry.seekai.passthroughModels, true); +}); + +test("#11786 seekai accepts any model id via passthrough", () => { + assert.equal(isValidModel("seekai", "claude-sonnet-5"), true); + assert.equal(isValidModel("ska", "gpt-5.6"), true); +}); + +test("#11786 seekai is on the aggregator list and display endpoint", async () => { + assert.equal(AGGREGATOR_PROVIDER_IDS.has("seekai"), true); + assert.equal(PROVIDER_ENDPOINTS.seekai, SEEKAI_CHAT_URL); + assert.ok((await getExecutor("seekai")) instanceof DefaultExecutor); +}); diff --git a/tests/unit/stream-payload-collector-cap.test.ts b/tests/unit/stream-payload-collector-cap.test.ts new file mode 100644 index 0000000000..a737c7a88d --- /dev/null +++ b/tests/unit/stream-payload-collector-cap.test.ts @@ -0,0 +1,57 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createStructuredSSECollector } = + await import("../../open-sse/utils/streamPayloadCollector.ts"); + +// Live incident (2026-09-02): a reasoning-heavy response streams reasoning +// token-by-token as hundreds to thousands of tiny SSE deltas before the real +// output/tool_calls ever arrive. At the old defaults (200 events / 48KB), +// the cap was routinely exhausted during the reasoning phase alone, +// dropping the completion event entirely -- for a caller with no `format` +// (no live reducer), the logged summary is reconstructed from getEvents() +// (see open-sse/utils/stream.ts), so the dropped completion silently +// produced a served-successfully response logged as _truncated with an +// empty output array. src/lib/db/responsesContinuationStore.ts then had +// nothing real to reconstruct a later continuation turn from. Measured +// live: ~22% of a sample of recent successful responses hit this. +test("createStructuredSSECollector retains a realistic reasoning-heavy event burst without dropping (regression for the 2026-09-02 truncated-continuation incident)", () => { + const collector = createStructuredSSECollector({ stage: "client_response" }); + + // Anonymized, real-incident shape: ~1600 small reasoning deltas (the + // observed volume for a genuinely reasoning-heavy turn) followed by the + // actual completion event -- exactly the ordering that exhausted the old + // 200-event/48KB cap before the completion event ever arrived. + for (let i = 0; i < 1600; i++) { + collector.push({ + type: "response.reasoning_summary_text.delta", + delta: "token ", + sequence_number: i, + }); + } + collector.push({ + type: "response.completed", + response: { id: "resp_test", status: "completed", output: [{ type: "message" }] }, + }); + + const built = collector.build(undefined, { includeEvents: true }); + assert.equal(built._truncated, undefined, "a realistic reasoning burst must not hit the cap"); + assert.equal(built._droppedEvents, undefined); + + const events = collector.getEvents(); + const completedEvent = events.find((e) => e.data?.type === "response.completed"); + assert.ok(completedEvent, "the completion event must survive to build()'s retained events"); +}); + +test("createStructuredSSECollector still reports _truncated once a stream genuinely exceeds the (raised) cap", () => { + // The cap protects against a truly pathological/runaway stream -- raising + // it must not remove that protection, only its false-positive rate on + // realistic reasoning-heavy traffic. + const collector = createStructuredSSECollector({ stage: "client_response", maxEvents: 5 }); + for (let i = 0; i < 10; i++) { + collector.push({ type: "response.output_text.delta", delta: "x", sequence_number: i }); + } + const built = collector.build(undefined, { includeEvents: false }); + assert.equal(built._truncated, true); + assert.equal(built._droppedEvents, 5); +}); diff --git a/tests/unit/system-transforms.test.ts b/tests/unit/system-transforms.test.ts index 047063d663..e5683766eb 100644 --- a/tests/unit/system-transforms.test.ts +++ b/tests/unit/system-transforms.test.ts @@ -559,7 +559,7 @@ const UI_DEFAULTS_SNAPSHOT = { entrypoint: "sdk-cli", versionFormat: "ex-machina", cchAlgo: "sha256-first-user", - buildRevision: "1f2", + buildRevision: "1e2", }, ], }, diff --git a/tests/unit/translator-clova-v3.test.ts b/tests/unit/translator-clova-v3.test.ts new file mode 100644 index 0000000000..fb9851f7d3 --- /dev/null +++ b/tests/unit/translator-clova-v3.test.ts @@ -0,0 +1,825 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Naver CLOVA Studio "Chat Completions v3" translator pair. +// +// The guard that matters most here is the stream-duplication case: `event: token` +// carries an INCREMENTAL delta while the terminal `event: result` repeats the +// COMPLETE text. Concatenating both doubles the whole answer at the end of the +// stream, so the result event must contribute finish_reason + usage only. +// +// Wire docs: https://api.ncloud-docs.com/docs/clovastudio-chatcompletionsv3 + +const request = await import("../../open-sse/translator/request/openai-to-clova.ts"); +const response = await import("../../open-sse/translator/response/clova-to-openai.ts"); +const registry = await import("../../open-sse/translator/registry.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); +const { getExecutor } = await import("../../open-sse/executors/index.ts"); + +// --------------------------------------------------------------------------- +// Request: OpenAI → CLOVA v3 +// --------------------------------------------------------------------------- + +test("clova v3: registers the request and response translator pair", () => { + assert.ok(registry.getRequestTranslator(FORMATS.OPENAI, FORMATS.CLOVA)); + assert.ok(registry.getResponseTranslator(FORMATS.CLOVA, FORMATS.OPENAI)); +}); + +test("clova v3: its executor appends and URL-encodes the selected model", async () => { + const executor = await getExecutor("clova-studio"); + assert.equal( + executor.buildUrl("HCX 005", true), + "https://clovastudio.stream.ntruss.com/v3/chat-completions/HCX%20005" + ); +}); + +test("clova v3: string content becomes a typed text part", () => { + const body = { messages: [{ role: "user", content: "hello" }] }; + const payload = request.buildClovaPayload("HCX-005", body, true, null); + assert.deepEqual(payload.messages[0], { + role: "user", + content: [{ type: "text", text: "hello" }], + }); +}); + +test("clova v3: sampling params are camelCased", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + max_tokens: 512, + top_p: 0.8, + top_k: 4, + temperature: 0.5, + repetition_penalty: 1.15, + seed: 42, + stop: ["END"], + }, + true, + null + ); + assert.equal(payload.maxTokens, 512); + assert.equal(payload.topP, 0.8); + assert.equal(payload.topK, 4); + assert.equal(payload.temperature, 0.5); + assert.equal(payload.repetitionPenalty, 1.15); + assert.equal(payload.seed, 42); + assert.deepEqual(payload.stop, ["END"]); + // snake_case must not leak upstream. + assert.equal(payload.max_tokens, undefined); + assert.equal(payload.top_p, undefined); +}); + +test("clova v3: output tokens are clamped to the documented 4096 cap", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], max_tokens: 100000 }, + true, + null + ); + assert.equal(payload.maxTokens, request.CLOVA_V3_MAX_OUTPUT_TOKENS); +}); + +test("clova v3: max_completion_tokens on a text model still maps to maxTokens", () => { + // Only reasoning models speak `maxCompletionTokens`; for text models the cap is + // `maxTokens` regardless of which OpenAI alias the client used. + const payload = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], max_completion_tokens: 1024 }, + true, + null + ); + assert.equal(payload.maxTokens, 1024); + assert.equal(payload.maxCompletionTokens, undefined); +}); + +test("clova v3: model and stream are not sent in the body", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { model: "HCX-005", stream: true, messages: [{ role: "user", content: "hi" }] }, + true, + null + ); + // The model travels in the URL path and streaming is driven by Accept. + assert.equal(payload.model, undefined); + assert.equal(payload.stream, undefined); +}); + +// --------------------------------------------------------------------------- +// Function calling (v3-fc) — same endpoint, different body fields +// --------------------------------------------------------------------------- + +test("clova v3: tools are translated and toolChoice auto is forwarded", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "Weather in Seoul?" }], + tools: [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather for a city", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, + }, + }, + ], + tool_choice: "auto", + }, + true, + null + ); + assert.deepEqual(payload.tools, [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather for a city", + parameters: { + type: "object", + properties: { location: { type: "string" } }, + required: ["location"], + }, + }, + }, + ]); + assert.equal(payload.toolChoice, "auto"); +}); + +test("clova v3: toolChoice none is forwarded; a forced choice is dropped", () => { + // Live-verified: `toolChoice: {type:"function", function:{name}}` returns + // `40009 Unsupported function` — CLOVA only accepts "auto" and "none". + const none = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + tool_choice: "none", + }, + true, + null + ); + assert.equal(none.toolChoice, "none"); + + const forced = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + tool_choice: { type: "function", function: { name: "f" } }, + }, + true, + null + ); + assert.equal(forced.toolChoice, undefined); +}); + +test("clova v3: function calling raises the cap to the documented 1024 minimum", () => { + // Live-verified: any cap below 1024 fails with + // `40001 Invalid parameter: tools, maxTokens`. + const below = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + max_tokens: 128, + }, + true, + null + ); + assert.equal(below.maxTokens, request.CLOVA_V3_MIN_TOOL_TOKENS); + + const absent = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + }, + true, + null + ); + assert.equal(absent.maxTokens, request.CLOVA_V3_MIN_TOOL_TOKENS); + + const above = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + max_tokens: 2048, + }, + true, + null + ); + assert.equal(above.maxTokens, 2048); +}); + +test("clova v3: function calling forces thinking.effort none on the reasoning model", () => { + // Live-verified: HCX-007 without it returns + // `40001 Invalid parameter: tools, thinking`. + const payload = request.buildClovaPayload( + "HCX-007", + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + max_tokens: 2048, + }, + true, + null + ); + assert.deepEqual(payload.thinking, { effort: "none" }); + assert.equal(payload.maxCompletionTokens, 2048); + assert.equal(payload.maxTokens, undefined); +}); + +test("clova v3: non-reasoning models never receive a thinking field", () => { + // Regression guard: HCX-005 and HCX-DASH-002 reject `thinking` outright + // (live-verified: `40001 Invalid parameter: thinking`), even with tools. + for (const model of ["HCX-005", "HCX-DASH-002"]) { + const withTools = request.buildClovaPayload( + model, + { + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "f" } }], + }, + true, + null + ); + assert.equal(withTools.thinking, undefined, `${model} must not receive thinking`); + assert.ok(Array.isArray(withTools.tools)); + + const askingForReasoning = request.buildClovaPayload( + model, + { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + true, + null + ); + assert.equal(askingForReasoning.thinking, undefined, `${model} ignores reasoning_effort`); + } +}); + +test("clova v3: images are dropped in function-calling mode", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ], + tools: [{ type: "function", function: { name: "f" } }], + }, + true, + null + ); + assert.deepEqual(payload.messages[0], { role: "user", content: "describe" }); + assert.ok(!JSON.stringify(payload).includes("imageUrl")); +}); + +test("clova v3: a tool result round-trips as role tool with toolCallId", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { role: "user", content: "Weather in Seoul?" }, + { + role: "assistant", + content: "", + tool_calls: [ + { + id: "call_abc", + type: "function", + function: { name: "get_weather", arguments: '{"location":"Seoul"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_abc", content: '{"temp":17}' }, + ], + tools: [{ type: "function", function: { name: "get_weather" } }], + }, + true, + null + ); + + assert.deepEqual(payload.messages[1], { + role: "assistant", + content: "", + toolCalls: [ + { + id: "call_abc", + type: "function", + function: { name: "get_weather", arguments: { location: "Seoul" } }, + }, + ], + }); + // CLOVA wants `arguments` as an object; OpenAI sends a JSON string. + assert.equal(typeof payload.messages[1].toolCalls[0].function.arguments, "object"); + + assert.deepEqual(payload.messages[2], { + role: "tool", + content: '{"temp":17}', + toolCallId: "call_abc", + }); +}); + +// --------------------------------------------------------------------------- +// Structured Outputs (v3-so) — HCX-007 only +// --------------------------------------------------------------------------- + +test("clova v3: json_schema maps onto responseFormat", () => { + const schema = { + type: "object", + properties: { temp_high_c: { type: "number" } }, + required: ["temp_high_c"], + }; + const payload = request.buildClovaPayload( + "HCX-007", + { + messages: [{ role: "user", content: "..." }], + response_format: { type: "json_schema", json_schema: { name: "weather", schema } }, + }, + true, + null + ); + assert.deepEqual(payload.responseFormat, { type: "json", schema }); + // Structured Outputs cannot be combined with reasoning (live-verified). + assert.deepEqual(payload.thinking, { effort: "none" }); +}); + +test("clova v3: structured outputs are dropped off the HCX-007-only path", () => { + // HCX-005 rejects `thinking` outright, so SO is unavailable there + // (live-verified: `40001 Invalid parameter: thinking`). + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [{ role: "user", content: "..." }], + response_format: { + type: "json_schema", + json_schema: { name: "x", schema: { type: "object" } }, + }, + }, + true, + null + ); + assert.equal(payload.responseFormat, undefined); + assert.equal(payload.thinking, undefined); +}); + +test("clova v3: function calling wins over structured outputs", () => { + const payload = request.buildClovaPayload( + "HCX-007", + { + messages: [{ role: "user", content: "..." }], + tools: [{ type: "function", function: { name: "f" } }], + response_format: { + type: "json_schema", + json_schema: { name: "x", schema: { type: "object" } }, + }, + }, + true, + null + ); + assert.ok(Array.isArray(payload.tools)); + assert.equal(payload.responseFormat, undefined); +}); + +test("clova v3: a public image URL maps to imageUrl.url", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ], + }, + true, + null + ); + const parts = payload.messages[0].content; + assert.deepEqual(parts[1], { + type: "image_url", + imageUrl: { url: "https://example.com/a.png" }, + }); +}); + +test("clova v3: base64 images keep their full data-URI prefix in dataUri.data", () => { + // Regression guard: the prefix MUST survive. Sending only the base64 payload + // (prefix stripped) makes CLOVA reject the whole request with + // `40001 Invalid parameter`, while the complete data-URI string is accepted. + // Live-verified 2026-09-01 with PNG and JPEG at 16x16, 64x64 and full size. + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "data:image/png;base64,AAAABBBB" } }, + { type: "text", text: "what is this" }, + ], + }, + ], + }, + true, + null + ); + assert.deepEqual(payload.messages[0].content[0], { + type: "image_url", + dataUri: { data: "data:image/png;base64,AAAABBBB" }, + }); + assert.deepEqual(payload.messages[0].content[1], { type: "text", text: "what is this" }); +}); + +test("clova v3: a data: image never leaks into imageUrl.url", () => { + const payload = request.buildClovaPayload( + "HCX-005", + { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/jpeg;base64,ZZZZ" } }], + }, + ], + }, + true, + null + ); + const part = payload.messages[0].content[0]; + assert.equal(part.imageUrl, undefined); + assert.deepEqual(part.dataUri, { data: "data:image/jpeg;base64,ZZZZ" }); +}); + +test("clova v3: images are stripped for a text-only model", () => { + const payload = request.buildClovaPayload( + "HCX-DASH-002", + { + messages: [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + { type: "text", text: "describe" }, + ], + }, + ], + }, + true, + null + ); + assert.deepEqual(payload.messages[0].content, [{ type: "text", text: "describe" }]); +}); + +// --------------------------------------------------------------------------- +// Reasoning model (HCX-007) contract +// --------------------------------------------------------------------------- + +test("clova v3: reasoning models use maxCompletionTokens, never maxTokens", () => { + // Live-verified: HCX-007 answers 40001 "Invalid parameter: maxTokens" when the + // cap is sent as `maxTokens`, and succeeds with `maxCompletionTokens`. + const withMaxTokens = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], max_tokens: 1024 }, + true, + null + ); + assert.equal(withMaxTokens.maxCompletionTokens, 1024); + assert.equal(withMaxTokens.maxTokens, undefined); + + const withMaxCompletion = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], max_completion_tokens: 2048 }, + true, + null + ); + assert.equal(withMaxCompletion.maxCompletionTokens, 2048); +}); + +test("clova v3: reasoning output cap is 32768, not the 4096 text-model cap", () => { + const payload = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], max_tokens: 999999 }, + true, + null + ); + assert.equal(payload.maxCompletionTokens, request.CLOVA_V3_REASONING_MAX_OUTPUT_TOKENS); + + const textModel = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], max_tokens: 999999 }, + true, + null + ); + assert.equal(textModel.maxTokens, request.CLOVA_V3_MAX_OUTPUT_TOKENS); +}); + +test("clova v3: stop is dropped for reasoning models", () => { + // The vendor docs state `stop` cannot be used while thinking. + const reasoning = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], stop: ["END"] }, + true, + null + ); + assert.equal(reasoning.stop, undefined); + + const text = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], stop: ["END"] }, + true, + null + ); + assert.deepEqual(text.stop, ["END"]); +}); + +test("clova v3: images are stripped for the reasoning model (HCX-007 has no vision)", () => { + const payload = request.buildClovaPayload( + "HCX-007", + { + messages: [ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + { type: "text", text: "describe" }, + ], + }, + ], + }, + true, + null + ); + assert.deepEqual(payload.messages[0].content, [{ type: "text", text: "describe" }]); +}); + +test("clova v3: reasoning_effort maps onto thinking.effort", () => { + assert.equal(request.toClovaThinkingEffort("low"), "low"); + assert.equal(request.toClovaThinkingEffort("high"), "high"); + // OpenAI's `minimal` has no CLOVA equivalent; `low` is the closest. + assert.equal(request.toClovaThinkingEffort("minimal"), "low"); + // Unknown values are omitted so CLOVA applies its own default. + assert.equal(request.toClovaThinkingEffort("bogus"), ""); + + const payload = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + true, + null + ); + assert.deepEqual(payload.thinking, { effort: "high" }); + + const noEffort = request.buildClovaPayload( + "HCX-007", + { messages: [{ role: "user", content: "hi" }] }, + true, + null + ); + assert.equal(noEffort.thinking, undefined); + + // Non-reasoning models must never receive the thinking envelope. + const textModel = request.buildClovaPayload( + "HCX-005", + { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" }, + true, + null + ); + assert.equal(textModel.thinking, undefined); +}); + +// --------------------------------------------------------------------------- +// Response: CLOVA v3 → OpenAI +// --------------------------------------------------------------------------- + +function tokenFrame(text: string): string { + return ( + `id: aabb\n` + + `event: token\n` + + `data: ${JSON.stringify({ message: { role: "assistant", content: text }, finishReason: null, created: 1 })}\n\n` + ); +} + +function resultFrame(fullText: string): string { + return ( + `id: aabb\n` + + `event: result\n` + + `data: ${JSON.stringify({ + message: { role: "assistant", content: fullText }, + finishReason: "stop", + created: 1, + usage: { promptTokens: 20, completionTokens: 5, totalTokens: 25 }, + })}\n\n` + ); +} + +test("clova v3: a token frame emits an incremental delta", () => { + const state = {}; + const chunk = response.convertClovaToOpenAI(tokenFrame("안"), state); + assert.equal(chunk.choices[0].delta.content, "안"); + // First chunk carries the assistant role, per OpenAI semantics. + assert.equal(chunk.choices[0].delta.role, "assistant"); + assert.equal(chunk.choices[0].finish_reason, null); +}); + +test("clova v3: the result frame does NOT repeat the already-streamed text", () => { + const state = {}; + response.convertClovaToOpenAI(tokenFrame("안"), state); + response.convertClovaToOpenAI(tokenFrame("녕"), state); + const terminal = response.convertClovaToOpenAI(resultFrame("안녕"), state); + + // The snapshot text must not be re-emitted — this is the duplication guard. + assert.equal(terminal.choices[0].delta.content, undefined); + assert.deepEqual(terminal.choices[0].delta, {}); + assert.equal(terminal.choices[0].finish_reason, "stop"); +}); + +test("clova v3: a full token→result stream yields the answer exactly once", () => { + const state = {}; + const frames = [tokenFrame("안"), tokenFrame("녕"), resultFrame("안녕")]; + const text = frames + .map((frame) => response.convertClovaToOpenAI(frame, state)) + .filter(Boolean) + .map((chunk) => chunk.choices?.[0]?.delta?.content ?? "") + .join(""); + + assert.equal(text, "안녕"); + assert.notEqual(text, "안녕안녕"); + assert.deepEqual(state.usage, { + prompt_tokens: 20, + completion_tokens: 5, + total_tokens: 25, + }); +}); + +test("clova v3: an upstream status failure surfaces as state.upstreamError", () => { + const state = {}; + const frame = + `id: aabb\n` + + `event: error\n` + + `data: ${JSON.stringify({ status: { code: "40100", message: "Invalid API key" } })}\n\n`; + + assert.equal(response.convertClovaToOpenAI(frame, state), null); + assert.equal(state.upstreamError.status, 400); + assert.match(state.upstreamError.message, /Invalid API key/); +}); + +test("clova v3: a 5xxxx status maps to a 502 upstream error", () => { + const state = {}; + const payload = { + status: { code: "50000", message: "Internal Server Error" }, + result: null, + }; + assert.equal(response.convertClovaToOpenAI(payload, state), null); + assert.equal(state.upstreamError.status, 502); +}); + +test("clova v3: a non-stream envelope replays its text once, then terminates", () => { + const state = {}; + const out = response.convertClovaToOpenAI( + { + status: { code: "20000", message: "OK" }, + result: { + message: { role: "assistant", content: "hello" }, + usage: { promptTokens: 1, completionTokens: 2, totalTokens: 3 }, + finishReason: "stop", + }, + }, + state + ); + + assert.ok(Array.isArray(out)); + assert.equal(out[0].choices[0].delta.content, "hello"); + assert.equal(out[1].choices[0].finish_reason, "stop"); + assert.equal(state.usage.total_tokens, 3); +}); + +test("clova v3: thinkingContent is emitted as reasoning_content", () => { + const state = {}; + const frame = + `id: aabb\n` + + `event: token\n` + + `data: ${JSON.stringify({ message: { role: "assistant", thinkingContent: "생각" }, finishReason: null })}\n\n`; + + const chunk = response.convertClovaToOpenAI(frame, state); + assert.equal(chunk.choices[0].delta.reasoning_content, "생각"); + assert.equal(chunk.choices[0].delta.content, undefined); +}); + +test("clova v3: reasoning and answer deltas stay on separate delta keys", () => { + const state = {}; + const thinking = response.convertClovaToOpenAI( + `event: token\ndata: ${JSON.stringify({ message: { thinkingContent: "because" } })}\n\n`, + state + ); + const answer = response.convertClovaToOpenAI( + `event: token\ndata: ${JSON.stringify({ message: { content: "391" } })}\n\n`, + state + ); + + assert.equal(thinking.choices[0].delta.reasoning_content, "because"); + assert.equal(answer.choices[0].delta.content, "391"); + assert.equal(answer.choices[0].delta.reasoning_content, undefined); +}); + +test("clova v3: a tool-call stream assembles partialJson fragments", () => { + const state = {}; + const frame = (data: unknown, event = "token") => + `id: x\nevent: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + + // First frame carries id + name; the rest carry only JSON fragments. + const start = response.convertClovaToOpenAI( + frame({ + message: { + role: "assistant", + content: "", + toolCalls: [{ id: "call_abc", type: "function", function: { name: "get_weather" } }], + }, + finishReason: null, + }), + state + ); + assert.deepEqual(start.choices[0].delta.tool_calls, [ + { + index: 0, + id: "call_abc", + type: "function", + function: { name: "get_weather", arguments: "" }, + }, + ]); + + // Fragment order is taken verbatim from a live HCX-005 function-calling + // stream — note the space after the colon, which CLOVA emits as its own chunk. + let args = ""; + for (const fragment of ['{"', "location", '":', ' "', "Se", "oul", '"}']) { + const chunk = response.convertClovaToOpenAI( + frame({ + message: { + role: "assistant", + content: "", + toolCalls: [{ type: "function", function: { partialJson: fragment } }], + }, + finishReason: null, + }), + state + ); + args += chunk.choices[0].delta.tool_calls[0].function.arguments; + } + assert.equal(args, '{"location": "Seoul"}'); + assert.deepEqual(JSON.parse(args), { location: "Seoul" }); +}); + +test("clova v3: the terminal frame reports tool_calls without repeating the call", () => { + const state = {}; + response.convertClovaToOpenAI( + `id: x\nevent: token\ndata: ${JSON.stringify({ message: { content: "", toolCalls: [{ id: "call_abc", type: "function", function: { name: "get_weather" } }] }, finishReason: null })}\n\n`, + state + ); + + const terminal = response.convertClovaToOpenAI( + `id: x\nevent: result\ndata: ${JSON.stringify({ + message: { + role: "assistant", + content: "", + toolCalls: [ + { + id: "call_abc", + type: "function", + function: { name: "get_weather", arguments: { location: "Seoul" } }, + }, + ], + }, + finishReason: "tool_calls", + usage: { promptTokens: 9, completionTokens: 47, totalTokens: 56 }, + })}\n\n`, + state + ); + + // The finished call is a snapshot — it must not be emitted a second time. + assert.equal(terminal.choices[0].delta.tool_calls, undefined); + assert.deepEqual(terminal.choices[0].delta, {}); + assert.equal(terminal.choices[0].finish_reason, "tool_calls"); + assert.equal(terminal.usage.total_tokens, 56); +}); + +test("clova v3: the flush signal and unparseable frames return null", () => { + const state = {}; + assert.equal(response.convertClovaToOpenAI(null, state), null); + assert.equal(response.convertClovaToOpenAI("id: aabb\nevent: ping\ndata: \n\n", state), null); + assert.equal(response.convertClovaToOpenAI("not json at all", state), null); +}); + +test("clova v3: an unknown event type is ignored", () => { + const state = {}; + const frame = `event: signal\ndata: ${JSON.stringify({ data: "keepalive" })}\n\n`; + assert.equal(response.convertClovaToOpenAI(frame, state), null); +}); diff --git a/tests/unit/translator-resp-openai-responses.test.ts b/tests/unit/translator-resp-openai-responses.test.ts index 2253c655ac..3a42eca624 100644 --- a/tests/unit/translator-resp-openai-responses.test.ts +++ b/tests/unit/translator-resp-openai-responses.test.ts @@ -994,3 +994,143 @@ test("OpenAI -> Responses: a text message and a following tool call in the same "completed output must include the tool call" ); }); + +// Live incident (2026-09-02): a free-tier streaming model, after a short text +// preamble, opened two tool calls whose upstream `tool_calls[].index` was 1 +// and 2 -- never 0. toolCallOutputIndexBase()+index therefore emitted +// output_index 0 (message), 2, 3 -- skipping 1 entirely. A spec-following +// Responses-API client reads response.completed's final `output[]` array by +// ARRAY POSITION and expects position === output_index (the API's own +// contract): output[1] (this turn's first call, real output_index 2) gets +// looked up under output_index 1 and missed, then output[2] (the second +// call, real output_index 3) gets looked up under output_index 2 and +// collides with the FIRST call's tracked slot -- two different call_ids on +// what the client thinks is one identity, which it correctly refuses to +// treat as anything but a broken stream. Reproduced verbatim (anonymized +// content, same index/id shape) against OpenClaw's own +// createResponsesOutputTracker before this fix; content and tool/model names +// below are placeholders, not the real incident's. +test("OpenAI -> Responses: tool-call output_index stays gap-free when the upstream's own index doesn't start at 0", () => { + const events = collectEvents([ + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { index: 0, delta: { content: "Status:", role: "assistant" }, finish_reason: null }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { index: 0, delta: { content: " all clear.", role: "assistant" }, finish_reason: null }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [ + { + index: 1, + id: "call_stub_1", + type: "function", + function: { name: "notify", arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [{ index: 1, function: { arguments: '{"a":1}' } }], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [ + { + index: 2, + id: "call_stub_2", + type: "function", + function: { name: "notify", arguments: "" }, + }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { + index: 0, + delta: { + content: null, + role: "assistant", + tool_calls: [{ index: 2, function: { arguments: '{"a":2}' } }], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-gap1", + model: "stub-model", + choices: [ + { index: 0, delta: { content: "", role: "assistant" }, finish_reason: "tool_calls" }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }, + null, + ]); + + const addedEvents = events.filter((e) => e.event === "response.output_item.added"); + const indexes = addedEvents.map((e) => e.data.output_index).sort((a, b) => a - b); + const sequential = indexes.map((_, i) => i); + assert.deepEqual( + indexes, + sequential, + `output_index values must be a gap-free 0..n-1 sequence (position === output_index is the Responses API's own contract); got ${JSON.stringify(indexes)}` + ); + + // The exact client-observable symptom: response.completed's output[] + // array, read by array position, must match each item's own tracked + // output_index -- otherwise a client keying by array position resolves + // the wrong item. + const completedGap = events.find((e) => e.event === "response.completed"); + completedGap.data.response.output.forEach((item, position) => { + const addedEvent = addedEvents.find((e) => e.data.item?.id === item.id); + assert.equal( + addedEvent?.data.output_index, + position, + `item ${item.id} (type ${item.type}) streamed at output_index ${addedEvent?.data.output_index} but sits at array position ${position} in the completed output` + ); + }); +}); diff --git a/tests/unit/ui/compressionAdaptiveBudgetDial.test.tsx b/tests/unit/ui/compressionAdaptiveBudgetDial.test.tsx new file mode 100644 index 0000000000..5d4cd36afb --- /dev/null +++ b/tests/unit/ui/compressionAdaptiveBudgetDial.test.tsx @@ -0,0 +1,660 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { DEFAULT_CONTEXT_BUDGET } from "../../../open-sse/services/compression/adaptiveCompression/types.ts"; + +// i18n does not resolve to a real locale in vitest/jsdom, so mock next-intl to echo +// the key. This test asserts ONLY on i18n-independent hooks (data-testid + values) +// and the captured PUT body. +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +const containers: HTMLElement[] = []; +const roots: Array<{ unmount: () => void }> = []; + +function mount(ui: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + roots.push(root); + act(() => root.render(ui)); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await act(async () => { + while (roots.length > 0) roots.pop()?.unmount(); + }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + while (containers.length > 0) containers.pop()?.remove(); + document.body.innerHTML = ""; +}); + +async function flush() { + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +} + +interface CapturedPut { + url: string; + body: Record; +} + +function setupFetchMock( + overrides?: Record, + opts?: { putStatus?: number; putStatusFn?: (n: number) => number } +): { puts: CapturedPut[] } { + const puts: CapturedPut[] = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, + liveZone: { enabled: false }, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, + ...overrides, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) + return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + puts.push({ url, body }); + const n = puts.length; + const status = opts?.putStatusFn ? opts.putStatusFn(n) : (opts?.putStatus ?? 200); + const merged = + body.contextBudget && typeof body.contextBudget === "object" + ? { + ...initial, + ...body, + contextBudget: { + ...(initial.contextBudget as Record), + ...(body.contextBudget as Record), + }, + } + : { ...initial, ...body }; + return json(merged, status); + } + return json(initial); + } + return json({}, 404); + } + ); + return { puts }; +} + +describe("CompressionPanel adaptive context-budget dial", () => { + it("renders the mode select defaulting to off (legacy auto-trigger)", async () => { + setupFetchMock(); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement | null; + expect(select, "mode select must render").toBeTruthy(); + expect(select?.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + expect( + container.querySelector(`[data-testid="adaptive-target-preview"]`), + "preview label stays inside the dial" + ).toBeTruthy(); + }); + + it("hydrates mode off when GET omits contextBudget", async () => { + setupFetchMock({ contextBudget: undefined }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement | null; + expect(select?.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + }); + + it("hides the policy select when GET hydrates mode as null (same as off)", async () => { + setupFetchMock({ + contextBudget: { ...DEFAULT_CONTEXT_BUDGET, mode: null as unknown as "off" }, + }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement | null; + expect(select?.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + }); + + it("selecting floor PUTs the full contextBudget object with mode:'floor'", async () => { + const { puts } = setupFetchMock(); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + expect(select).toBeTruthy(); + await act(async () => { + select.value = "floor"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const put = puts.find((p) => "contextBudget" in p.body); + expect(put, "a PUT carrying contextBudget").toBeTruthy(); + const budget = put!.body.contextBudget as Record; + expect(budget.mode).toBe("floor"); + expect(budget.policy).toBe(DEFAULT_CONTEXT_BUDGET.policy); + expect(budget.outputReserve).toBe(DEFAULT_CONTEXT_BUDGET.outputReserve); + expect(budget.safetyMargin).toBe(DEFAULT_CONTEXT_BUDGET.safetyMargin); + expect(budget.pct).toBe(DEFAULT_CONTEXT_BUDGET.pct); + expect(budget.absoluteBudget).toBe(DEFAULT_CONTEXT_BUDGET.absoluteBudget); + expect(budget.ladderOverride).toBe(DEFAULT_CONTEXT_BUDGET.ladderOverride); + + expect( + container.querySelector(`[data-testid="context-budget-policy-select"]`), + "policy select appears once mode is not off" + ).toBeTruthy(); + }); + + it("selecting replace-autotrigger PUTs mode and reveals the policy select", async () => { + const { puts } = setupFetchMock(); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + select.value = "replace-autotrigger"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const put = puts.find((p) => "contextBudget" in p.body); + expect(put, "a PUT carrying contextBudget").toBeTruthy(); + const budget = put!.body.contextBudget as Record; + expect(budget.mode).toBe("replace-autotrigger"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeTruthy(); + }); + + it("rolls the mode select back to off when the PUT fails", async () => { + setupFetchMock(undefined, { putStatus: 500 }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const select = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + select.value = "floor"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const after = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + expect(after.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + }); + + it("rolls policy back to the hydrated value when the PUT fails", async () => { + const { puts } = setupFetchMock( + { + contextBudget: { + ...DEFAULT_CONTEXT_BUDGET, + mode: "floor", + policy: "reserve-output", + }, + }, + { putStatus: 500 } + ); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const policy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(policy.value).toBe("reserve-output"); + await act(async () => { + policy.value = "percentage"; + policy.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const after = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(after.value).toBe("reserve-output"); + expect(puts).toHaveLength(1); + const budget = puts[0].body.contextBudget as Record; + expect(budget.mode).toBe("floor"); + expect(budget.policy).toBe("percentage"); + expect(budget.outputReserve).toBe(DEFAULT_CONTEXT_BUDGET.outputReserve); + expect(budget.absoluteBudget).toBe(DEFAULT_CONTEXT_BUDGET.absoluteBudget); + }); + + it("does not let an older failed PUT roll back a newer successful save", async () => { + const puts: CapturedPut[] = []; + const pending: Array<{ + resolve: (r: Response) => void; + body: Record; + }> = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, + liveZone: { enabled: false }, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) + return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + puts.push({ url, body }); + return new Promise((resolve) => { + pending.push({ resolve, body }); + }); + } + return json(initial); + } + return json({}, 404); + } + ); + + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + const policy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement | null; + expect(policy).toBeFalsy(); + + // Two PUTs from the same render: older one will 500 after the newer one 200s. + await act(async () => { + mode.value = "floor"; + mode.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(1); + + const policyAfter = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(policyAfter, "policy select after optimistic floor").toBeTruthy(); + await act(async () => { + policyAfter.value = "percentage"; + policyAfter.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(2); + + await act(async () => { + pending[1].resolve(json({ ...initial, ...pending[1].body }, 200)); + }); + await flush(); + await act(async () => { + pending[0].resolve(json({ ...initial, ...pending[0].body }, 500)); + }); + await flush(); + + const afterMode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + const afterPolicy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(afterMode.value).toBe("floor"); + expect(afterPolicy.value).toBe("percentage"); + }); + + it("keeps an older successful PUT as lastConfirmed when a newer overlapping PUT fails", async () => { + const puts: CapturedPut[] = []; + const pending: Array<{ + resolve: (r: Response) => void; + body: Record; + }> = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, + liveZone: { enabled: false }, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) + return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + puts.push({ url, body }); + return new Promise((resolve) => { + pending.push({ resolve, body }); + }); + } + return json(initial); + } + return json({}, 404); + } + ); + + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + mode.value = "floor"; + mode.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(1); + + const policyAfter = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + await act(async () => { + policyAfter.value = "percentage"; + policyAfter.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(2); + + // Older save A acks first (stale gen). Newer save B then 500s. + await act(async () => { + pending[0].resolve(json({ ...initial, ...pending[0].body }, 200)); + }); + await flush(); + await act(async () => { + pending[1].resolve(json({ ...initial, ...pending[1].body }, 500)); + }); + await flush(); + + const afterMode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + const afterPolicy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(afterMode.value).toBe("floor"); + expect( + afterPolicy, + "policy select stays — lastConfirmed is A's floor, not GET off" + ).toBeTruthy(); + expect(afterPolicy.value).toBe("reserve-output"); + }); + + it("rolls both overlapping failed PUTs back to the last GET snapshot, not the first optimistic state", async () => { + const puts: CapturedPut[] = []; + const pending: Array<{ + resolve: (r: Response) => void; + body: Record; + }> = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + const initial = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + outputStyles: [], + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + ultraEngine: "heuristic", + ultraSlmPrewarm: false, + liveZone: { enabled: false }, + contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, + }; + vi.spyOn(globalThis, "fetch").mockImplementation( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/api/settings/compression/mcp-accessibility")) + return json({ enabled: true }); + if (url.includes("/api/settings/compression")) { + if (method === "PUT") { + const body = JSON.parse(String(init?.body ?? "{}")) as Record; + puts.push({ url, body }); + return new Promise((resolve) => { + pending.push({ resolve, body }); + }); + } + return json(initial); + } + return json({}, 404); + } + ); + + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + mode.value = "floor"; + mode.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(1); + + const policyAfter = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + await act(async () => { + policyAfter.value = "percentage"; + policyAfter.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(pending).toHaveLength(2); + + await act(async () => { + pending[1].resolve(json({ ...initial, ...pending[1].body }, 500)); + }); + await flush(); + await act(async () => { + pending[0].resolve(json({ ...initial, ...pending[0].body }, 500)); + }); + await flush(); + + const afterMode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + expect(afterMode.value).toBe("off"); + expect(container.querySelector(`[data-testid="context-budget-policy-select"]`)).toBeFalsy(); + }); + + it("does not let a stale saved-timeout clear a newer error status", async () => { + vi.useFakeTimers(); + try { + const { puts } = setupFetchMock(undefined, { + putStatusFn: (n) => (n === 1 ? 200 : 500), + }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + await act(async () => { + mode.value = "floor"; + mode.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + expect(puts).toHaveLength(1); + expect(container.textContent).toContain("saved"); + + const policy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + await act(async () => { + policy.value = "percentage"; + policy.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + expect(puts).toHaveLength(2); + expect(container.textContent).toContain("saveFailed"); + + await act(async () => { + vi.advanceTimersByTime(2000); + }); + await flush(); + expect(container.textContent).toContain("saveFailed"); + } finally { + vi.useRealTimers(); + } + }); + + it("hydrates GET contextBudget and changing policy PUTs the merged object", async () => { + const { puts } = setupFetchMock({ + contextBudget: { + ...DEFAULT_CONTEXT_BUDGET, + mode: "floor", + policy: "reserve-output", + }, + }); + const { default: CompressionPanel } = + await import("../../../src/app/(dashboard)/dashboard/context/settings/CompressionPanel"); + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const mode = container.querySelector( + `[data-testid="context-budget-mode-select"]` + ) as HTMLSelectElement; + const policy = container.querySelector( + `[data-testid="context-budget-policy-select"]` + ) as HTMLSelectElement; + expect(mode.value).toBe("floor"); + expect(policy, "policy select must hydrate when mode is floor").toBeTruthy(); + expect(policy.value).toBe("reserve-output"); + + await act(async () => { + policy.value = "percentage"; + policy.dispatchEvent(new Event("change", { bubbles: true })); + }); + await flush(); + + const put = puts.find((p) => "contextBudget" in p.body); + expect(put, "a PUT carrying contextBudget").toBeTruthy(); + const budget = put!.body.contextBudget as Record; + expect(budget.mode).toBe("floor"); + expect(budget.policy).toBe("percentage"); + expect(budget.pct).toBe(DEFAULT_CONTEXT_BUDGET.pct); + expect(budget.ladderOverride).toBe(DEFAULT_CONTEXT_BUDGET.ladderOverride); + }); +}); diff --git a/tests/unit/ui/connectionsSearchFilter.test.tsx b/tests/unit/ui/connectionsSearchFilter.test.tsx index 6885c1a91c..3b2e9ab7ce 100644 --- a/tests/unit/ui/connectionsSearchFilter.test.tsx +++ b/tests/unit/ui/connectionsSearchFilter.test.tsx @@ -35,6 +35,11 @@ const CONNECTIONS: ConnectionRowConnection[] = [ { id: "conn-2", name: "Bob", email: "bob@example.com", providerSpecificData: { tag: "staging" } }, { id: "conn-3", name: "Carol", email: "carol@gmail.com" }, { id: "special-id-9", name: undefined, email: undefined }, + { + id: "conn-grade", + name: "Grade-S-Node", + providerSpecificData: { tag: "relay", baseUrl: "http://145.10.20.30:8080" }, + }, ]; describe("matchesAccountQuery / filterConnectionsByQuery — #7937", () => { @@ -74,6 +79,20 @@ describe("matchesAccountQuery / filterConnectionsByQuery — #7937", () => { it("does not match a connection missing the queried field", () => { expect(matchesAccountQuery("anything", CONNECTIONS[3])).toBe(false); }); + + // #12108 — detail-page search must also match providerSpecificData.baseUrl + // (import stores the override there; id/tag/name/email never contain the host). + it("matches providerSpecificData.baseUrl by host substring (#12108)", () => { + expect(matchesAccountQuery("145.10.20.30", CONNECTIONS[4])).toBe(true); + expect(matchesAccountQuery("145.10.20.30", CONNECTIONS[0])).toBe(false); + expect(filterConnectionsByQuery("145.10.20.30", CONNECTIONS).map((c) => c.id)).toEqual([ + "conn-grade", + ]); + }); + + it("matches providerSpecificData.baseUrl case-insensitively (#12108)", () => { + expect(matchesAccountQuery("HTTP://145.10.20.30:8080", CONNECTIONS[4])).toBe(true); + }); }); // --------------------------------------------------------------------------- diff --git a/tests/unit/universal-handoff.test.ts b/tests/unit/universal-handoff.test.ts index 45c06f963f..addf4b633e 100644 --- a/tests/unit/universal-handoff.test.ts +++ b/tests/unit/universal-handoff.test.ts @@ -237,7 +237,13 @@ test("buildUniversalHandoffSystemMessage basic when payload null", () => { test("buildUniversalHandoffSystemMessage basic when payload summary empty", () => { const msg = buildUniversalHandoffSystemMessage(PREV, CURR, REASON, makePayload({ summary: "" })); - assert.ok(msg.includes("continuar sin perder el hilo")); + // The bare-fallback note must not claim continuity it can't provide: a + // model landing here with only trimmed input (e.g. a bare tool result) + // and no real history has been observed fabricating plausible-sounding + // but entirely invented content when told "the conversation continues + // without losing context" -- the note now tells it the opposite. + assert.ok(msg.includes("No prior-session summary is available")); + assert.ok(msg.includes("do not assume or invent")); }); test("buildUniversalHandoffSystemMessage full XML with valid payload", () => { diff --git a/tests/unit/video-bridge-derived-prompt-redaction.test.ts b/tests/unit/video-bridge-derived-prompt-redaction.test.ts new file mode 100644 index 0000000000..1e31e75ea1 --- /dev/null +++ b/tests/unit/video-bridge-derived-prompt-redaction.test.ts @@ -0,0 +1,184 @@ +// tests/unit/video-bridge-derived-prompt-redaction.test.ts +// P2c of #12150/#12430 (Video Bridge transcript retention — derived-prompt +// dispatch logs, item 4). +// +// Seam trace finding (decisive): videoBridgeLog is ALREADY threaded end-to-end +// to every nested handleChatCore — pipeline-strategy stages +// (src/domain/pipeline.ts::executeStage), smart-auto-pipeline, and +// context-handoff summaries (open-sse/services/contextHandoff.ts) — because +// all of them dispatch through the single P1b `handleSingleModel` closure and +// terminate in the SAME handleChatCore -> persistAttemptLogs -> +// applyVideoBridgeLogRedaction logging path. No plumbing/param changes were +// needed anywhere. +// +// The gap this file proves closed: those derived dispatches embed the +// transcript as a SUBSTRING of a plain STRING `content` message — +// `{ role: "user", content: }` — built by executeStage() +// (pipeline.ts:196-199 via prompts.ts interpolation) and by the +// context-handoff summary builders (contextHandoff.ts:415/729, `{HISTORY}` +// template substitution). Before this fix, applyVideoBridgeLogRedaction only +// matched ARRAY-content parts by exact text (`part.text === fullText`), so it +// silently skipped these string-content messages and the raw transcript +// persisted in the stage/summary sub-request call logs. +// +// This suite calls the real, already-exported `applyVideoBridgeLogRedaction` +// (open-sse/handlers/chatCore/attemptLogging.ts) directly — it is a pure +// function (no DB), so no persistAttemptLogs/DB harness is needed here; that +// integration-level proof already lives in +// tests/unit/video-bridge-log-redaction.test.ts. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { applyVideoBridgeLogRedaction } from "../../open-sse/handlers/chatCore/attemptLogging.ts"; +import type { VideoBridgeLogRedactionEntry } from "../../src/lib/guardrails/videoBridge.ts"; + +const SECRET = "secret words"; +const FULL_TEXT = `[Video description: transcript[source=client] ${SECRET}]`; +const REDACTED_TEXT = "[Video description: transcript[source=client] [redacted-video-transcript]]"; + +function entry( + overrides: Partial = {} +): VideoBridgeLogRedactionEntry { + return { + container: "messages", + messageIndex: 0, + partIndex: 0, + fullText: FULL_TEXT, + redactedText: REDACTED_TEXT, + ...overrides, + }; +} + +test("derived-prompt (pipeline stage): a string-content message with the transcript embedded as a substring is redacted, secret absent, surrounding prompt text intact", () => { + const body = { + model: "openai/gpt-x", + messages: [ + { role: "system", content: "You are a summarization stage." }, + { + role: "user", + content: `Summarize the following context.\n\n${FULL_TEXT}\n\nEnd of context.`, + }, + ], + }; + + const result = applyVideoBridgeLogRedaction(body, [ + entry({ messageIndex: 1, partIndex: 0 }), + ]) as typeof body; + + const redactedContent = result.messages[1].content; + assert.equal( + redactedContent, + `Summarize the following context.\n\n${REDACTED_TEXT}\n\nEnd of context.` + ); + assert.ok(!redactedContent.includes(SECRET), "the raw transcript must not survive redaction"); + assert.ok( + redactedContent.startsWith("Summarize the following context.\n\n"), + "surrounding prompt text before the blob must stay intact" + ); + assert.ok( + redactedContent.endsWith("\n\nEnd of context."), + "surrounding prompt text after the blob must stay intact" + ); + assert.equal(JSON.stringify(result).includes(SECRET), false); +}); + +test("derived-prompt (context-handoff summary): input container string content is redacted the same way as messages", () => { + const body = { + model: "openai/gpt-x", + input: [ + { + role: "user", + content: `Continue the conversation given this history.\n\n${FULL_TEXT}\n\nContinue now.`, + }, + ], + }; + + const result = applyVideoBridgeLogRedaction(body, [ + entry({ container: "input", messageIndex: 0, partIndex: 0 }), + ]) as typeof body; + + const redactedContent = result.input[0].content; + assert.equal( + redactedContent, + `Continue the conversation given this history.\n\n${REDACTED_TEXT}\n\nContinue now.` + ); + assert.ok(!redactedContent.includes(SECRET)); + assert.equal(JSON.stringify(result).includes(SECRET), false); +}); + +test("multiple occurrences of fullText within the same string are ALL replaced (replaceAll, not replace)", () => { + const body = { + messages: [ + { + role: "user", + content: `First mention: ${FULL_TEXT}\n\nQuoted back for grounding: ${FULL_TEXT}\n\nDone.`, + }, + ], + }; + + const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]) as { + messages: Array<{ content: string }>; + }; + + const redactedContent = result.messages[0].content; + assert.equal( + redactedContent, + `First mention: ${REDACTED_TEXT}\n\nQuoted back for grounding: ${REDACTED_TEXT}\n\nDone.` + ); + assert.equal( + redactedContent.split(REDACTED_TEXT).length - 1, + 2, + "both occurrences must be replaced" + ); + assert.ok(!redactedContent.includes(SECRET)); +}); + +test("regression: the existing ARRAY-content exact-part-match path still redacts (no regression from the new string branch)", () => { + const body = { + messages: [ + { role: "system", content: "sys" }, + { + role: "user", + content: [ + { type: "text", text: "look at this video" }, + { type: "text", text: FULL_TEXT }, + ], + }, + ], + }; + + const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 1, partIndex: 1 })]) as { + messages: Array<{ content: unknown }>; + }; + + const content = result.messages[1].content as Array<{ text: string }>; + assert.equal(content[1].text, REDACTED_TEXT); + assert.ok(!content[1].text.includes(SECRET)); + assert.equal(content[0].text, "look at this video", "sibling part must stay untouched"); +}); + +test("no mutation of the input object: the caller's body is byte-identical after redaction (string-content path)", () => { + const body = { + messages: [{ role: "user", content: `before ${FULL_TEXT} after` }], + }; + const snapshotBefore = JSON.parse(JSON.stringify(body)); + + applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]); + + assert.deepEqual(body, snapshotBefore, "the original body must never be mutated"); +}); + +test("non-matching string content is returned unchanged, with the SAME root reference (nothing redacted -> no clone allocated)", () => { + const body = { + messages: [{ role: "user", content: "nothing to see here, no transcript blob at all" }], + }; + + const result = applyVideoBridgeLogRedaction(body, [entry({ messageIndex: 0, partIndex: 0 })]); + + assert.equal( + result, + body, + "when no fullText matches, the exact same object reference is returned" + ); +}); diff --git a/tests/unit/video-bridge-log-redaction.test.ts b/tests/unit/video-bridge-log-redaction.test.ts index d190db80c4..268102d246 100644 --- a/tests/unit/video-bridge-log-redaction.test.ts +++ b/tests/unit/video-bridge-log-redaction.test.ts @@ -26,6 +26,8 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { logClientRawRequestRedacted } from "../../src/lib/guardrails/videoBridgeSnapshotRedaction.ts"; + const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-video-log-redaction-test-")); process.env.DATA_DIR = testDataDir; @@ -265,3 +267,79 @@ test("Scenario A (adversarial review): a message prepended AFTER the guardrail b "the prepended system message must be untouched" ); }); + +// #12150 P2 surface 1 (the dominant transcript-retention leak): the RAW client-request +// snapshot passed to reqLogger.logClientRawRequest (open-sse/handlers/chatCore.ts's +// "0. Log client raw request" step) is a DIFFERENT sink from persistAttemptLogs above — +// it is captured before the guardrail chain even runs, so it carries the client's raw +// `transcript`/`audioTranscript` FIELDS on a structured video part, not a flattened +// description string. Importing the real chatCore.ts here would pull the full +// request-pipeline dependency graph (executors, providers, combo routing, DB-backed +// settings, ...) into the test just to reach one guarded call a few hundred lines into +// a 5900+ line handler, for no additional proof beyond what's below — so this calls the +// REAL exported `logClientRawRequestRedacted` (the exact function chatCore.ts's call site +// invokes, post file-size-refactor) against a fake logClientRawRequest. The pure redaction +// helper itself has its own thorough suite in +// tests/unit/guardrails/videoBridgeSnapshotRedaction.test.ts. +function fakeReqLogger() { + const calls: unknown[] = []; + return { + calls, + logClientRawRequest(_endpoint: unknown, body: unknown, _headers?: unknown) { + calls.push(body); + }, + }; +} + +test("surface 2 (raw snapshot): the fake logClientRawRequest receives a redacted snapshot only when videoBridgeObserved is true", () => { + const rawBody = { + model: "openai/gpt-x", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "look at this video" }, + { + type: "input_video", + video_url: "https://example.com/clip.mp4", + transcript: { cues: [{ text: SECRET, startSeconds: 0, endSeconds: 2 }] }, + }, + ], + }, + ], + }; + const clientRawRequest = { endpoint: "/v1/chat/completions", body: rawBody, headers: {} }; + + const observedLogger = fakeReqLogger(); + logClientRawRequestRedacted(observedLogger, clientRawRequest, true); + const observedSnapshot = observedLogger.calls[0]; + assert.ok( + !JSON.stringify(observedSnapshot).includes(SECRET), + "an observed request must not log the raw transcript" + ); + assert.notEqual( + observedSnapshot, + rawBody, + "the observed path must log a redacted CLONE, not the original reference" + ); + assert.ok( + JSON.stringify(rawBody).includes(SECRET), + "clientRawRequest.body itself must stay untouched for every other consumer (translation/dispatch)" + ); + + const nonObservedLogger = fakeReqLogger(); + logClientRawRequestRedacted(nonObservedLogger, clientRawRequest, false); + assert.equal( + nonObservedLogger.calls[0], + rawBody, + "the non-observed path must log the exact same object reference — byte-identical, no clone" + ); + + const skippedLogger = fakeReqLogger(); + logClientRawRequestRedacted(skippedLogger, null, true); + assert.equal( + skippedLogger.calls.length, + 0, + "a missing clientRawRequest must not call logClientRawRequest at all (mirrors the old if-guard)" + ); +}); diff --git a/tests/unit/webpack-create-require-warning.test.ts b/tests/unit/webpack-create-require-warning.test.ts index 7f454c614a..9c375c52bd 100644 --- a/tests/unit/webpack-create-require-warning.test.ts +++ b/tests/unit/webpack-create-require-warning.test.ts @@ -75,6 +75,10 @@ async function compileRuntimeRequireModules(): Promise { "sqlite-vec", "playwright", "wreq-js", + // browserPool.ts imports `./obscura.ts`. The isolated webpack compile + // has no repo tree, so treat the sibling as external instead of + // erroring "Can't resolve './obscura.ts'". + "./obscura.ts", ], externalsPresets: { node: true }, mode: "development", diff --git a/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts b/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts index 926697d31e..46bb23266a 100644 --- a/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts +++ b/tests/unit/zai-web-chat-endpoint-8014-probe.test.ts @@ -9,7 +9,7 @@ const TEST_TOKEN = "e30.eyJpZCI6InVzZXItMTIzIn0.sig"; /** * #8014 guard: the executor must target the versioned v2 completions endpoint, * never the stale unversioned `/api/chat/completions` path, which 404s - * model-independently as of 2026-07. + * model-independently. * * Setup notes for this flow (the executor now creates a remote chat first and * signs the completion request): @@ -43,7 +43,7 @@ test("#8014: ZaiWebExecutor must POST to the current chat.z.ai v2 chat-completio try { const executor = new mod.ZaiWebExecutor(); const result = await executor.execute({ - model: "glm-4.6", + model: "glm-5.3", body: { messages: [{ role: "user", content: "hello" }] }, stream: false, credentials: { @@ -55,7 +55,6 @@ test("#8014: ZaiWebExecutor must POST to the current chat.z.ai v2 chat-completio assert.ok(requested.length > 0, "the direct path must actually reach fetch"); assert.ok( - // Exact-URL match (not a substring test): `requested` holds whole URLs. !requested.some((url) => url === STALE_URL), `zai-web executor POSTed to the stale endpoint — matches #8014's model-independent 404 "Not Found"` ); diff --git a/tests/unit/zai-web-models-discovery-7678.test.ts b/tests/unit/zai-web-models-discovery-7678.test.ts index 82837c541f..380c4ba504 100644 --- a/tests/unit/zai-web-models-discovery-7678.test.ts +++ b/tests/unit/zai-web-models-discovery-7678.test.ts @@ -13,7 +13,7 @@ const providersDb = await import("../../src/lib/db/providers.ts"); const modelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts"); const registry = await import("../../open-sse/config/providers/registry/zai-web/index.ts"); -const CURATED_ZAI_WEB_MODEL_IDS = ["glm-5.2", "GLM-5.1", "GLM-5-Turbo", "GLM-5v-Turbo"]; +const CURATED_ZAI_WEB_MODEL_IDS = ["glm-5.3-flash", "glm-5.3", "glm-5.2"]; async function resetStorage() { core.resetDbInstance(); @@ -31,34 +31,32 @@ test("zai-web publishes the live reasoning and vision capabilities", () => { registry.zai_webProvider.models.map((model) => ({ id: model.id, supportsReasoning: model.supportsReasoning === true, + supportedThinkingEfforts: model.supportedThinkingEfforts, supportsVision: model.supportsVision === true, toolCalling: model.toolCalling === true, })), [ + { + id: "glm-5.3-flash", + supportsReasoning: true, + supportedThinkingEfforts: ["low", "high", "max"], + supportsVision: true, + toolCalling: false, + }, + { + id: "glm-5.3", + supportsReasoning: true, + supportedThinkingEfforts: ["low", "high", "max"], + supportsVision: false, + toolCalling: false, + }, { id: "glm-5.2", supportsReasoning: true, + supportedThinkingEfforts: ["high", "max"], supportsVision: false, toolCalling: false, }, - { - id: "GLM-5.1", - supportsReasoning: true, - supportsVision: false, - toolCalling: false, - }, - { - id: "GLM-5-Turbo", - supportsReasoning: true, - supportsVision: false, - toolCalling: false, - }, - { - id: "GLM-5v-Turbo", - supportsReasoning: true, - supportsVision: true, - toolCalling: false, - }, ] ); });